From 21774188309b626e73f6515557f54107e31d6d01 Mon Sep 17 00:00:00 2001 From: Sibi Krishnan Date: Thu, 10 Sep 2026 17:07:21 -0400 Subject: [PATCH] feat(sdk-core): dkls derive round orchestrator Ticket: WCN-2340 --- modules/bitgo/package.json | 2 +- .../ecdsaVrfMPCv2/createSafeChildKeychains.ts | 343 ++++++++++++++++ modules/sdk-core/package.json | 2 +- modules/sdk-core/src/bitgo/safe/iSafe.ts | 7 +- modules/sdk-core/src/bitgo/safe/safe.ts | 143 +++++-- .../src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts | 27 +- .../utils/tss/ecdsa/ecdsaMPCv2KeyGenSender.ts | 41 +- .../bitgo/utils/tss/ecdsa/ecdsaVrfMPCv2.ts | 291 +++++++++++++- .../src/bitgo/utils/tss/ecdsa/typesMPCv2.ts | 16 + modules/sdk-core/test/unit/bitgo/safe/safe.ts | 80 +++- modules/sdk-lib-mpc/src/tss/dkls-vrf/util.ts | 75 ++++ .../sdk-lib-mpc/src/tss/ecdsa-dkls/derive.ts | 368 ++++++++++++++++++ .../sdk-lib-mpc/src/tss/ecdsa-dkls/index.ts | 1 + .../test/unit/tss/dkls-vrf/derive.ts | 207 ++++++++++ yarn.lock | 11 + 15 files changed, 1545 insertions(+), 69 deletions(-) create mode 100644 modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaVrfMPCv2/createSafeChildKeychains.ts create mode 100644 modules/sdk-lib-mpc/src/tss/ecdsa-dkls/derive.ts create mode 100644 modules/sdk-lib-mpc/test/unit/tss/dkls-vrf/derive.ts diff --git a/modules/bitgo/package.json b/modules/bitgo/package.json index 74f24c6c43..812a16d297 100644 --- a/modules/bitgo/package.json +++ b/modules/bitgo/package.json @@ -144,7 +144,7 @@ "superagent": "^9.0.1" }, "devDependencies": { - "@bitgo/public-types": "6.70.1", + "@bitgo/public-types": "6.72.1", "@bitgo/sdk-opensslbytes": "^2.1.0", "@bitgo/sdk-test": "^9.1.76", "@openpgp/web-stream-tools": "0.0.14", diff --git a/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaVrfMPCv2/createSafeChildKeychains.ts b/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaVrfMPCv2/createSafeChildKeychains.ts new file mode 100644 index 0000000000..af06b6979f --- /dev/null +++ b/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaVrfMPCv2/createSafeChildKeychains.ts @@ -0,0 +1,343 @@ +import * as assert from 'assert'; +import nock = require('nock'); +import * as openpgp from 'openpgp'; +import { decode } from 'cbor-x'; + +import { TestableBG, TestBitGo } from '@bitgo/sdk-test'; +import { AddKeychainOptions, common, ECDSAUtils, Wallet } from '@bitgo/sdk-core'; +import { DklsComms, DklsDrv, DklsTypes, DklsUtils, DklsVrfUtils } from '@bitgo/sdk-lib-mpc'; +import { MPCv2DeriveRound1Request, MPCv2DeriveRound2Request, MPCv2DeriveRound3Request } from '@bitgo/public-types'; +import { NonEmptyString } from 'io-ts-types'; +import { BitGo, BitgoGPGPublicKey } from '../../../../../../src'; + +const SAFE_ID = '6fa8537e3ef5a878fd3ae899f3ab7e5a'; +const USER_ROOT_KEY_ID = 'root-user-key-id'; +const DERIVATION_INDEX = 0; +const BITGO_ROOT_KEY_ID = 'root-bitgo-key-id'; +const BACKUP_ROOT_KEY_ID = 'root-backup-key-id'; +const PATH_M0 = new Uint8Array([0x80, 0x00, 0x00, 0x00]); + +describe('TSS ECDSA safe child keychains (user/BitGo hard derive):', async function () { + const coinName = 'hteth'; + const enterpriseId = '6449153a6f6bc20006d66771cdbe15d3'; + let bgUrl: string; + let bitgo: TestableBG & BitGo; + let tssUtils: ECDSAUtils.EcdsaVrfMPCv2Utils; + let wallet: Wallet; + let bitGoGpgKey: openpgp.SerializedKeyPair & { + revocationCertificate: string; + }; + let constants: { mpc: { bitgoPublicKey: string; bitgoMPCv2PublicKey: string } }; + let bitgoGpgPrvKey: { partyId: number; gpgKey: string }; + let userGpgPubKey: { partyId: number; gpgKey: string }; + let stagedBitgoMsg2: { message: string; signature: string } | undefined; + + before(async function () { + openpgp.config.rejectCurves = new Set(); + bitGoGpgKey = await openpgp.generateKey({ + userIDs: [{ name: 'bitgo', email: 'bitgo@test.com' }], + curve: 'secp256k1', + }); + constants = { + mpc: { + bitgoPublicKey: bitGoGpgKey.publicKey, + bitgoMPCv2PublicKey: bitGoGpgKey.publicKey, + }, + }; + bitgoGpgPrvKey = { partyId: 2, gpgKey: bitGoGpgKey.privateKey }; + + bitgo = TestBitGo.decorate(BitGo, { env: 'mock' }); + bitgo.initializeTestVars(); + bgUrl = common.Environments[bitgo.getEnv()].uri; + + const baseCoin = bitgo.coin(coinName); + wallet = new Wallet(bitgo, baseCoin, { + id: '5b34252f1bf349930e34020a00000000', + enterprise: enterpriseId, + coin: coinName, + coinSpecific: {}, + multisigType: 'tss', + }); + tssUtils = new ECDSAUtils.EcdsaVrfMPCv2Utils(bitgo, baseCoin, wallet); + }); + + beforeEach(async function () { + nock.cleanAll(); + stagedBitgoMsg2 = undefined; + await nockGetBitgoPublicKeyBasedOnFeatureFlags(coinName, enterpriseId, bitGoGpgKey); + nock(bgUrl).get('/api/v1/client/constants').times(32).reply(200, { ttl: 3600, constants }); + }); + + after(function () { + nock.cleanAll(); + }); + + it('should derive the user child and register the backup without encryptedPrv', async function () { + const [userRoot, , bitgoRoot] = await DklsUtils.generateDKGKeyShares(); + const [vrfUser, , vrfBitgo] = await DklsVrfUtils.generateVrfDKGKeyShares(); + const bitgoPair = new DklsDrv.Derive(3, 2, 2, bitgoRoot.getKeyShare(), vrfBitgo.getKeyShare(), PATH_M0); + + const round1Nock = await nockDeriveRound1(bitgoPair); + const round2Nock = await nockDeriveRound2(bitgoPair); + const round3Nock = await nockDeriveRound3(bitgoPair); + const addKeyNock = await nockAddChildKey(coinName, 2); + + const { userKeychain, backupKeychain } = await tssUtils.createSafeChildKeychains({ + passphrase: 'test', + enterprise: enterpriseId, + safeId: SAFE_ID, + parentKeyId: BITGO_ROOT_KEY_ID, + derivationIndex: DERIVATION_INDEX, + userRootKeyId: USER_ROOT_KEY_ID, + backupRootKeyId: BACKUP_ROOT_KEY_ID, + userRootKeyShare: userRoot.getKeyShare(), + userRootVrfKeyShare: vrfUser.getKeyShare(), + }); + + assert.ok(round1Nock.isDone()); + assert.ok(round2Nock.isDone()); + assert.ok(round3Nock.isDone()); + assert.ok(addKeyNock.isDone()); + assert.equal(userKeychain.commonKeychain, backupKeychain.commonKeychain); + assert.equal(userKeychain.commonKeychain, DklsTypes.getCommonKeychain(bitgoPair.getKeyShare())); + + const encryptedUserPrv = userKeychain.encryptedPrv; + assert.ok(encryptedUserPrv); + assert.equal(backupKeychain.encryptedPrv, undefined); + const decryptedUserPrv = await bitgo.decrypt({ input: encryptedUserPrv, password: 'test' }); + const userChildShare = decode(Buffer.from(decryptedUserPrv, 'base64')); + assert.equal(userChildShare.version, undefined); + assert.equal(userChildShare.vrf, undefined); + assert.equal(userChildShare.party_id, 0); + assert.ok(userChildShare.s_i); + }); + + it('should derive at a non-zero hardened index and agree with the server', async function () { + const idx = 7; + const path = new Uint8Array([0x80 | (idx >>> 24), 0, 0, idx]); + const [userRoot, , bitgoRoot] = await DklsUtils.generateDKGKeyShares(); + const [vrfUser, , vrfBitgo] = await DklsVrfUtils.generateVrfDKGKeyShares(); + const bitgoPair = new DklsDrv.Derive(3, 2, 2, bitgoRoot.getKeyShare(), vrfBitgo.getKeyShare(), path); + + const round1Nock = await nockDeriveRound1(bitgoPair, 1, idx); + const round2Nock = await nockDeriveRound2(bitgoPair); + const round3Nock = await nockDeriveRound3(bitgoPair); + const addKeyNock = await nockAddChildKey(coinName, 2, idx); + + const { userKeychain, backupKeychain } = await tssUtils.createSafeChildKeychains({ + passphrase: 'test', + enterprise: enterpriseId, + safeId: SAFE_ID, + parentKeyId: BITGO_ROOT_KEY_ID, + derivationIndex: idx, + userRootKeyId: USER_ROOT_KEY_ID, + backupRootKeyId: BACKUP_ROOT_KEY_ID, + userRootKeyShare: userRoot.getKeyShare(), + userRootVrfKeyShare: vrfUser.getKeyShare(), + }); + + assert.ok(round1Nock.isDone()); + assert.ok(round2Nock.isDone()); + assert.ok(round3Nock.isDone()); + assert.ok(addKeyNock.isDone()); + assert.equal(userKeychain.commonKeychain, backupKeychain.commonKeychain); + }); + + it('should reject root key material that is not a valid VRF envelope', async function () { + const [, , bitgoRoot] = await DklsUtils.generateDKGKeyShares(); + const [, , vrfBitgo] = await DklsVrfUtils.generateVrfDKGKeyShares(); + const bitgoPair = new DklsDrv.Derive(3, 2, 2, bitgoRoot.getKeyShare(), vrfBitgo.getKeyShare(), PATH_M0); + const round1Nock = await nockDeriveRound1(bitgoPair); + const round2Nock = await nockDeriveRound2(bitgoPair); + const round3Nock = await nockDeriveRound3(bitgoPair); + + await assert.rejects( + () => + tssUtils.createSafeChildKeychains({ + passphrase: 'test', + enterprise: enterpriseId, + safeId: SAFE_ID, + parentKeyId: BITGO_ROOT_KEY_ID, + derivationIndex: DERIVATION_INDEX, + userRootKeyId: USER_ROOT_KEY_ID, + backupRootKeyId: BACKUP_ROOT_KEY_ID, + userRootKeyShare: Buffer.from('garbage'), + userRootVrfKeyShare: vrfBitgo.getKeyShare(), + }), + /CBOR decode|does not match root key share partyId|VRF keyshare/i + ); + assert.ok(!round1Nock.isDone(), 'round 1 must not be sent for invalid root material'); + assert.ok(!round2Nock.isDone(), 'round 2 must not be sent for invalid root material'); + assert.ok(!round3Nock.isDone(), 'round 3 must not be sent for invalid root material'); + }); + + it('should reject a root blob with a VRF partyId mismatch', async function () { + const [userRoot, , bitgoRoot] = await DklsUtils.generateDKGKeyShares(); + const [, vrfBackup, vrfBitgo] = await DklsVrfUtils.generateVrfDKGKeyShares(); + const bitgoPair = new DklsDrv.Derive(3, 2, 2, bitgoRoot.getKeyShare(), vrfBitgo.getKeyShare(), PATH_M0); + const round1Nock = await nockDeriveRound1(bitgoPair); + const round2Nock = await nockDeriveRound2(bitgoPair); + const round3Nock = await nockDeriveRound3(bitgoPair); + + await assert.rejects( + () => + tssUtils.createSafeChildKeychains({ + passphrase: 'test', + enterprise: enterpriseId, + safeId: SAFE_ID, + parentKeyId: BITGO_ROOT_KEY_ID, + derivationIndex: DERIVATION_INDEX, + userRootKeyId: USER_ROOT_KEY_ID, + backupRootKeyId: BACKUP_ROOT_KEY_ID, + userRootKeyShare: userRoot.getKeyShare(), + userRootVrfKeyShare: vrfBackup.getKeyShare(), + }), + /does not match VRF key share partyId/ + ); + assert.ok(!round1Nock.isDone(), 'round 1 must not be sent for mismatched VRF material'); + assert.ok(!round2Nock.isDone(), 'round 2 must not be sent for mismatched VRF material'); + assert.ok(!round3Nock.isDone(), 'round 3 must not be sent for mismatched VRF material'); + }); + + async function nockGetBitgoPublicKeyBasedOnFeatureFlags( + coin: string, + enterpriseId: string, + bitgoGpgKeyPair: openpgp.SerializedKeyPair + ): Promise { + const bitgoGPGPublicKeyResponse: BitgoGPGPublicKey = { + name: 'irrelevant', + publicKey: bitgoGpgKeyPair.publicKey, + mpcv2PublicKey: bitgoGpgKeyPair.publicKey, + enterpriseId, + }; + nock(bgUrl).get(`/api/v2/${coin}/tss/pubkey`).query({ enterpriseId }).reply(200, bitgoGPGPublicKeyResponse); + return bitgoGPGPublicKeyResponse; + } + + async function nockDeriveRound1(bitgoPair: DklsDrv.Derive, times = 1, index = DERIVATION_INDEX) { + return nock(bgUrl) + .post( + '/api/v2/mpc/generatekey', + (body) => + body.round === 'MPCv2Derive-R1' && + body.safeId === SAFE_ID && + body.payload?.parentKeyId === BITGO_ROOT_KEY_ID && + body.payload?.derivationIndex === index && + body.payload?.userGpgPublicKey && + body.payload?.userMsg1 && + body.payload?.backupGpgPublicKey === undefined && + body.payload?.backupMsg1 === undefined + ) + .times(times) + .reply(200, async (uri, requestBody: { payload: MPCv2DeriveRound1Request }) => { + const { userGpgPublicKey, userMsg1 } = requestBody.payload; + userGpgPubKey = { partyId: 0, gpgKey: userGpgPublicKey }; + await DklsComms.decryptAndVerifyIncomingMessages( + { + p2pMessages: [], + broadcastMessages: [{ from: 0, payload: { message: userMsg1.message, signature: userMsg1.signature } }], + }, + [userGpgPubKey], + [] + ); + + const bitgoMsg1Unsigned = await bitgoPair.initDerive(); + const bitgoMsg2 = bitgoPair.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [{ payload: Buffer.from(userMsg1.message, 'base64'), from: 0 }], + }); + const signedStagedMessages = await DklsComms.encryptAndAuthOutgoingMessages( + { + broadcastMessages: [DklsTypes.serializeBroadcastMessage(bitgoMsg2.broadcastMessages[0])], + p2pMessages: [], + }, + [], + [bitgoGpgPrvKey] + ); + stagedBitgoMsg2 = signedStagedMessages.broadcastMessages[0].payload; + + const signedMessages = await DklsComms.encryptAndAuthOutgoingMessages( + { + broadcastMessages: [DklsTypes.serializeBroadcastMessage(bitgoMsg1Unsigned)], + p2pMessages: [], + }, + [], + [bitgoGpgPrvKey] + ); + const bitgoMsg1 = signedMessages.broadcastMessages[0]; + assert.ok(bitgoMsg1, 'bitgoMsg1 not found'); + return { + sessionId: 'testid' as NonEmptyString, + bitgoMsg1: { from: 2, ...bitgoMsg1.payload }, + }; + }); + } + + async function nockDeriveRound2(bitgoPair: DklsDrv.Derive, times = 1) { + return nock(bgUrl) + .post( + '/api/v2/mpc/generatekey', + (body) => + body.round === 'MPCv2Derive-R2' && + body.safeId === SAFE_ID && + body.payload?.sessionId === 'testid' && + body.payload?.userMsg2 && + body.payload?.backupMsg2 === undefined + ) + .times(times) + .reply(200, async (uri, requestBody: { payload: MPCv2DeriveRound2Request }) => { + const { sessionId, userMsg2 } = requestBody.payload; + await DklsComms.decryptAndVerifyIncomingMessages( + { + p2pMessages: [], + broadcastMessages: [{ from: 0, payload: { message: userMsg2.message, signature: userMsg2.signature } }], + }, + [userGpgPubKey], + [] + ); + bitgoPair.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [{ payload: Buffer.from(userMsg2.message, 'base64'), from: 0 }], + }); + assert.ok(stagedBitgoMsg2, 'staged BitGo msg2 missing'); + return { sessionId, bitgoMsg2: { from: 2, ...stagedBitgoMsg2 } }; + }); + } + + async function nockDeriveRound3(bitgoPair: DklsDrv.Derive, times = 1) { + return nock(bgUrl) + .post( + '/api/v2/mpc/generatekey', + (body) => body.round === 'MPCv2Derive-R3' && body.safeId === SAFE_ID && body.payload?.sessionId === 'testid' + ) + .times(times) + .reply(200, async (uri, requestBody: { payload: MPCv2DeriveRound3Request }) => { + const { sessionId } = requestBody.payload; + return { + sessionId, + commonKeychain: DklsTypes.getCommonKeychain(bitgoPair.getKeyShare()) as NonEmptyString, + }; + }); + } + + async function nockAddChildKey(coin: string, times = 2, index = DERIVATION_INDEX) { + return nock('https://bitgo.fakeurl') + .post( + `/api/v2/${coin}/key`, + (body: AddKeychainOptions & { derivedFromParentWithPath?: string }) => + body.keyType === 'tss' && + body.isMPCv2 === true && + body.safeId === SAFE_ID && + !!body.parent && + body.derivedFromParentWithPath === `m/${index}'` + ) + .times(times) + .reply(200, (uri, requestBody: AddKeychainOptions) => ({ + id: requestBody.source, + source: requestBody.source, + type: requestBody.keyType, + commonKeychain: requestBody.commonKeychain, + encryptedPrv: requestBody.encryptedPrv, + })); + } +}); diff --git a/modules/sdk-core/package.json b/modules/sdk-core/package.json index 09abac95ce..fbdccbd20c 100644 --- a/modules/sdk-core/package.json +++ b/modules/sdk-core/package.json @@ -40,7 +40,7 @@ ] }, "dependencies": { - "@bitgo/public-types": "6.70.1", + "@bitgo/public-types": "6.72.1", "@bitgo/sdk-lib-mpc": "^10.20.0", "@bitgo/secp256k1": "^1.11.1", "@bitgo/sjcl": "^1.1.0", diff --git a/modules/sdk-core/src/bitgo/safe/iSafe.ts b/modules/sdk-core/src/bitgo/safe/iSafe.ts index 86c5a4eb47..3136b90776 100644 --- a/modules/sdk-core/src/bitgo/safe/iSafe.ts +++ b/modules/sdk-core/src/bitgo/safe/iSafe.ts @@ -31,7 +31,7 @@ export interface FinalizeSafeOptions { } /** - * Sharing ONE safe wallet with a non-member rides the existing wallet-share handshake (FR-13), + * Sharing ONE safe wallet with a non-member rides the existing wallet-share handshake, * so the result is the existing WalletShare shape. */ export type WalletShareData = WalletShare; @@ -43,7 +43,10 @@ export interface CreateSafeWalletOptions { label: string; passphrase: string; type?: 'hot'; - /** `tss` throws until MPC mint lands. Defaults to `onchain`. */ + /** + * `onchain` (default) mints a secp256k1 multisig wallet; `tss` mints an MPC + * wallet by deriving child keys from the safe's MPC roots. + */ multisigType?: 'onchain' | 'tss'; } diff --git a/modules/sdk-core/src/bitgo/safe/safe.ts b/modules/sdk-core/src/bitgo/safe/safe.ts index a1b198c7df..766d43b125 100644 --- a/modules/sdk-core/src/bitgo/safe/safe.ts +++ b/modules/sdk-core/src/bitgo/safe/safe.ts @@ -11,6 +11,7 @@ import { IBaseCoin } from '../baseCoin'; import { BitGoBase } from '../bitgoBase'; import { IncorrectPasswordError } from '../errors'; import { decryptKeychainPrivateKey } from '../keychain'; +import { ECDSAUtils } from '../utils'; import { boundedInt, decodeWithCodec } from '../utils/codecs'; import { postWithCodec } from '../utils/postWithCodec'; import { Wallet } from '../wallet'; @@ -37,17 +38,27 @@ const GetDerivationIndexResponse = t.type({ index: boundedInt(0, 0x7fffffff, 'derivationIndex'), }); -const CreateWalletInSafeBody = t.strict({ - coin: t.string, - label: t.string, - type: t.literal('hot'), - multisigType: t.literal('onchain'), - keys: t.tuple([t.string]), -}); +const CreateWalletInSafeBody = t.union([ + t.strict({ + coin: t.string, + label: t.string, + type: t.literal('hot'), + multisigType: t.literal('onchain'), + keys: t.tuple([t.string]), + }), + // TSS mint uses ordered user and backup child documents. + t.strict({ + coin: t.string, + label: t.string, + type: t.literal('hot'), + multisigType: t.literal('tss'), + keys: t.tuple([t.string, t.string]), + }), +]); function onchainSlotForCoin(coin: IBaseCoin): Extract { if (coin.getDefaultMultisigType() === 'tss') { - throw new Error('MPC safe wallet minting is not yet implemented; use a slot-1 onchain coin'); + throw new Error('MPC safe wallet minting requires multisigType "tss"; use "onchain" for non-MPC minting'); } const curve = coins.get(coin.getChain()).primaryKeyCurve; if (curve === KeyCurve.Secp256k1) { @@ -59,13 +70,27 @@ function onchainSlotForCoin(coin: IBaseCoin): Extract { + if (coin.getDefaultMultisigType() !== 'tss') { + throw new Error(`Coin '${coin.getChain()}' is not a TSS coin; cannot mint a tss safe wallet for it`); + } + const curve = coins.get(coin.getChain()).primaryKeyCurve; + if (curve === KeyCurve.Secp256k1) { + return 'ecdsaMpc'; + } + if (curve === KeyCurve.Ed25519) { + throw new Error('ed25519 MPC safe wallet minting is not yet supported'); + } + throw new Error(`Coin '${coin.getChain()}' is not supported for safe wallet minting`); +} + +function rootIdFromSafe(safe: SafeData, slot: RootKeyType, position: 0 | 1 | 2): string | undefined { const triplet = safe.rootKeys?.hot?.[slot]; if (!triplet || triplet.length !== 3) { return undefined; } - const userRootId = triplet[0]; - return userRootId.length > 0 ? userRootId : undefined; + const rootId = triplet[position]; + return rootId.length > 0 ? rootId : undefined; } /** @@ -105,8 +130,14 @@ export class Safe implements ISafe { } /** - * Mint a child wallet: peek the sequential index, hardened-derive the user child, - * register it public-only, then mint. Backup and BitGo children are soft-derived on the server. + * Mint a child wallet: peek the sequential index, derive the child keys, register + * them, then mint. + * + * `onchain`: hardened-derive the user child (`m/'`), register it + * public-only; backup and BitGo children are soft-derived on the server. + * + * `tss`: decrypt the user root blob, run the user/BitGo hard-derive ceremony, + * register the ordered child documents, then mint. */ async createWallet(params: CreateSafeWalletOptions): Promise { if (params.passphrase.length === 0) { @@ -115,12 +146,10 @@ export class Safe implements ISafe { if (params.type !== undefined && params.type !== 'hot') { throw new Error('Safe wallets are hot-only in v1'); } - if (params.multisigType === 'tss') { - throw new Error('MPC safe wallet minting is not yet implemented; use multisigType "onchain"'); - } + const isTss = params.multisigType === 'tss'; const coin = this.bitgo.coin(params.coin); - const slot = onchainSlotForCoin(coin); + const slot = isTss ? tssSlotForCoin(coin) : onchainSlotForCoin(coin); const indexResponse = await this.bitgo.get(this.url('/derivation-index')).query({ slot }).result(); const peeked = decodeWithCodec(GetDerivationIndexResponse, indexResponse, 'GetDerivationIndexResponse'); @@ -129,11 +158,24 @@ export class Safe implements ISafe { } const { index } = peeked; - const userRootId = userRootIdFromSafe(this._safe, slot) ?? userRootIdFromSafe(await this.fetchSafeData(), slot); + const safeData = rootIdFromSafe(this._safe, slot, 0) !== undefined ? this._safe : await this.fetchSafeData(); + const userRootId = rootIdFromSafe(safeData, slot, 0); if (userRootId === undefined) { throw new Error(`Safe ${this.id()} is missing rootKeys.hot.${slot}`); } + if (isTss) { + const backupRootId = rootIdFromSafe(safeData, slot, 1); + const bitgoRootId = rootIdFromSafe(safeData, slot, 2); + if (backupRootId === undefined) { + throw new Error(`Safe ${this.id()} is missing rootKeys.hot.${slot} backup key`); + } + if (bitgoRootId === undefined) { + throw new Error(`Safe ${this.id()} is missing rootKeys.hot.${slot} bitgo key`); + } + return this.createTssWalletInSafe(coin, userRootId, backupRootId, bitgoRootId, index, params); + } + const keychains = coin.keychains(); const rootKeychain = await keychains.get({ id: userRootId }); if (rootKeychain.source !== 'user') { @@ -175,6 +217,57 @@ export class Safe implements ISafe { return new Wallet(this.bitgo, coin, response); } + /** + * TSS wallet mint: decrypt the user root blob, run the user/BitGo hard-derive + * ceremony, register the ordered child documents, then mint. + */ + private async createTssWalletInSafe( + coin: IBaseCoin, + userRootId: string, + backupRootId: string, + bitgoRootId: string, + index: number, + params: CreateSafeWalletOptions + ): Promise { + const keychains = coin.keychains(); + const userRootKeychain = await keychains.get({ id: userRootId }); + if (userRootKeychain.source !== 'user') { + throw new InvalidRootKeychainSourceError(userRootKeychain.id, userRootKeychain.source); + } + + const userRootPrv = await decryptKeychainPrivateKey(this.bitgo, userRootKeychain, params.passphrase); + if (!userRootPrv) { + throw new IncorrectPasswordError(); + } + const userRootMaterial = ECDSAUtils.parseVrfKeyEnvelopes(userRootPrv); + + const tssUtils = new ECDSAUtils.EcdsaVrfMPCv2Utils(this.bitgo, coin); + const { userKeychain, backupKeychain } = await tssUtils.createSafeChildKeychains({ + passphrase: params.passphrase, + enterprise: this.enterpriseId(), + safeId: this.id(), + parentKeyId: bitgoRootId, + derivationIndex: index, + userRootKeyId: userRootId, + backupRootKeyId: backupRootId, + userRootKeyShare: userRootMaterial.signing, + userRootVrfKeyShare: userRootMaterial.vrf, + }); + if (userKeychain.id.length === 0 || backupKeychain.id.length === 0) { + throw new Error('safe child key registration returned an empty id'); + } + const keys: [string, string] = [userKeychain.id, backupKeychain.id]; + + const response = await postWithCodec(this.bitgo, this.url('/wallets'), CreateWalletInSafeBody, { + coin: params.coin, + label: params.label, + type: 'hot', + multisigType: 'tss', + keys, + }).result(); + return new Wallet(this.bitgo, coin, response); + } + private async fetchSafeData(): Promise { const response = await this.bitgo.get(this.url()).result(); return decodeWithCodec(SafeData, response, 'SafeData'); @@ -182,34 +275,30 @@ export class Safe implements ISafe { /** * Add a member to the whole safe (view/admin/spend). Spend opens a key share. - * Body lands in WCN-1204. */ async addMember(params: AddSafeMemberOptions): Promise { - throw new Error('Safe.addMember is not yet implemented (WCN-1204)'); + throw new Error('Safe.addMember is not yet implemented'); } /** - * Share ONE safe wallet with a non-member via the existing wallet-share handshake (FR-13). - * Body lands in WCN-1204. + * Share ONE safe wallet with a non-member via the existing wallet-share handshake. */ async addMemberToWallet(params: AddSafeWalletMemberOptions): Promise { - throw new Error('Safe.addMemberToWallet is not yet implemented (WCN-1204)'); + throw new Error('Safe.addMemberToWallet is not yet implemented'); } /** * List the safe key shares visible to the caller. - * Body lands in WCN-1204. */ async listShares(params: { state?: SafeShareState } = {}): Promise { - throw new Error('Safe.listShares is not yet implemented (WCN-1204)'); + throw new Error('Safe.listShares is not yet implemented'); } /** * Accept a safe key share addressed to the caller. - * Body lands in WCN-1204. */ async acceptShare(params: AcceptSafeShareOptions): Promise { - throw new Error('Safe.acceptShare is not yet implemented (WCN-1204)'); + throw new Error('Safe.acceptShare is not yet implemented'); } /** diff --git a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts index 787f0cf143..a0a5df4914 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts @@ -74,7 +74,7 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { retrofit?: DecryptedRetrofitPayload; webauthnInfo?: WebauthnKeyEncryptionInfo; encryptionVersion?: EncryptionVersion; - // Wallet Safes v1 (@experimental): tags the resulting user/backup/bitgo root keys with this safe. + // @experimental: tags the resulting user/backup/bitgo root keys with this safe. safeId?: string; }): Promise { const { userSession, backupSession } = this.getUserAndBackupSession(2, 3, params.retrofit); @@ -393,7 +393,10 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { }, encryptionVersion?: EncryptionVersion, enterprise?: string, - safeId?: string + safeId?: string, + // Safe child registration: the parent root key id this child was hardened-derived + // from, plus the derivation index (`m/'). + child?: { parentKeyId?: string; index?: number } ): Promise { let source: string; let encryptedPrv: string | undefined = undefined; @@ -403,9 +406,19 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { case MPCv2PartiesEnum.USER: case MPCv2PartiesEnum.BACKUP: source = participantIndex === MPCv2PartiesEnum.USER ? 'user' : 'backup'; + assert(passphrase, `Passphrase is required for ${source} keychain`); + if (privateMaterial === undefined) { + assert( + participantIndex === MPCv2PartiesEnum.BACKUP && + safeId !== undefined && + child?.parentKeyId !== undefined && + child.index !== undefined, + `Private material is required for ${source} keychain` + ); + break; + } assert(privateMaterial, `Private material is required for ${source} keychain`); assert(reducedPrivateMaterial, `Reduced private material is required for ${source} keychain`); - assert(passphrase, `Passphrase is required for ${source} keychain`); privateMaterialBase64 = privateMaterial.toString('base64'); if (encryptionSession) { encryptedPrv = await encryptionSession.encrypt(privateMaterialBase64); @@ -424,7 +437,7 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { // beyond the server-stored encryptedPrv. reducedEncryptedPrv = await this.bitgo.encrypt({ // Buffer.toString('base64') can not be used here as it does not work on the browser. - // The browser deals with a Buffer as Uint8Array, therefore in the browser .toString('base64') just creates a comma seperated string of the array values. + // The browser deals with a Buffer as Uint8Array, therefore on browser .toString('base64') just creates a comma seperated string of the array values. input: btoa(String.fromCharCode.apply(null, Array.from(new Uint8Array(reducedPrivateMaterial)))), password: passphrase, encryptionVersion, @@ -446,6 +459,8 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { originalPasscodeEncryptionCode, isMPCv2: true, safeId, + parent: child?.parentKeyId, + derivedFromParentWithPath: child?.index !== undefined ? `m/${child.index}'` : undefined, }; if (webauthnInfo && participantIndex === MPCv2PartiesEnum.USER && privateMaterialBase64) { @@ -1158,7 +1173,7 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { derivationPath = signableTx.derivationPath; serializedTxHex = signableTx.serializedTxHex; } else if (requestType === RequestType.message) { - // TODO(WP-2176): Add support for message signing + // TODO: add support for message signing throw new Error('MPCv2 message signing not supported yet.'); } else { throw new Error('Invalid request type, got: ' + requestType); @@ -1210,7 +1225,7 @@ export class EcdsaMPCv2Utils extends BaseEcdsaUtils { const { txRequest, reqId } = params; let txRequestResolved: TxRequest; - // TODO(WP-2176): Add support for message signing + // TODO: add support for message signing assert( requestType === RequestType.tx, 'Only transaction signing is supported for external signer, got: ' + requestType diff --git a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2KeyGenSender.ts b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2KeyGenSender.ts index 6e6d649022..db9b2d1023 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2KeyGenSender.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2KeyGenSender.ts @@ -1,5 +1,10 @@ import { KeyGenTypeEnum, MPCv2KeyGenState } from '@bitgo/public-types'; -import { GenerateMPCv2KeyRequestBody, GenerateMPCv2KeyRequestResponse } from './typesMPCv2'; +import { + GenerateMPCv2DeriveKeyRequest, + GenerateMPCv2DeriveKeyRequestResponse, + GenerateMPCv2KeyRequestBody, + GenerateMPCv2KeyRequestResponse, +} from './typesMPCv2'; import { BitGoBase } from '../../../bitgoBase'; export type EcdsaMPCv2KeyGenSendFn = ( @@ -10,8 +15,8 @@ export type EcdsaMPCv2KeyGenSendFn = export function KeyGenSenderForEnterprise( bitgo: BitGoBase, enterprise: string, - // Wallet Safes v1 (@experimental): when set, tags the resulting root keys with this safe. WP only reads it on - // round MPCv2-R1; passing it on a sender used solely for round 1 is sufficient. + // @experimental: when set, tags the resulting root keys with this safe. Only read + // on round MPCv2-R1; passing it on a sender used solely for round 1 is sufficient. safeId?: string ): EcdsaMPCv2KeyGenSendFn { return (round, payload) => { @@ -21,3 +26,33 @@ export function KeyGenSenderForEnterprise = ( + round: MPCv2KeyGenState, + payload: GenerateMPCv2DeriveKeyRequest +) => Promise; + +/** + * Round sender for the user/BitGo safe-child hard-derivation ceremony. The + * derive rounds use the same endpoint as MPCv2 keygen (`/mpc/generatekey`), + * dispatched by the `MPCv2Derive-R*` round values. The R1 payload carries the + * BitGo root `parentKeyId` and `derivationIndex`. + */ +export function KeyGenSenderForSafeChild( + bitgo: BitGoBase, + enterprise: string, + safeId: string +): EcdsaMPCv2DeriveKeySendFn { + return (round, payload) => { + return bitgo + .post(bitgo.url('/mpc/generatekey', 2)) + .send({ + enterprise, + safeId, + type: KeyGenTypeEnum.MPCv2, + round, + payload, + }) + .result(); + }; +} diff --git a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaVrfMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaVrfMPCv2.ts index 69f814ff06..f83e524d5e 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaVrfMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaVrfMPCv2.ts @@ -1,9 +1,17 @@ -import { DklsComms, DklsDkg, DklsTypes, DklsVrf } from '@bitgo/sdk-lib-mpc'; -import { encode } from 'cbor-x'; +import { DklsComms, DklsDkg, DklsDrv, DklsTypes, DklsVrf } from '@bitgo/sdk-lib-mpc'; +import { decode, encode } from 'cbor-x'; import assert from 'assert'; import { NonEmptyString } from 'io-ts-types'; -import { MPCv2KeyGenRound1Response, MPCv2KeyGenRound2Response, MPCv2KeyGenStateEnum } from '@bitgo/public-types'; - +import { + MPCv2DeriveRound1Request, + MPCv2DeriveRound1Response, + MPCv2DeriveRound2Request, + MPCv2DeriveRound2Response, + MPCv2DeriveRound3Response, + MPCv2KeyGenRound1Response, + MPCv2KeyGenRound2Response, + MPCv2KeyGenStateEnum, +} from '@bitgo/public-types'; import { KeychainsTriplet } from '../../../baseCoin'; import { DecryptedRetrofitPayload } from '../../../keychain/iKeychains'; import { EncryptionVersion } from '../../../../api'; @@ -11,7 +19,11 @@ import { generateGPGKeyPair } from '../../opengpgUtils'; import { WebauthnKeyEncryptionInfo } from '../../../keychain'; import { envRequiresBitgoPubGpgKeyConfig, isBitgoMpcPubKey } from '../../../tss/bitgoPubKeys'; import { EcdsaMPCv2Utils } from './ecdsaMPCv2'; -import { KeyGenSenderForEnterprise } from './ecdsaMPCv2KeyGenSender'; +import { + EcdsaMPCv2DeriveKeySendFn, + KeyGenSenderForEnterprise, + KeyGenSenderForSafeChild, +} from './ecdsaMPCv2KeyGenSender'; import { MPCv2PartiesEnum, MpcV2VrfKeyGenResponseFields } from './typesMPCv2'; /** @@ -21,6 +33,15 @@ import { MPCv2PartiesEnum, MpcV2VrfKeyGenResponseFields } from './typesMPCv2'; * the ordinary MPCv2 format does. */ const VRF_KEY_ENVELOPE_VERSION = 1; +type VrfKeyEnvelope = { + version: unknown; + prvKeyShare: unknown; + vrf: unknown; +}; + +function isVrfKeyEnvelope(value: unknown): value is VrfKeyEnvelope { + return typeof value === 'object' && value !== null && 'version' in value && 'prvKeyShare' in value && 'vrf' in value; +} /** * Wire format for VRF DKG messages riding the MPCv2-R1/R2 payloads: an opaque blob, @@ -83,6 +104,48 @@ export function buildVrfKeyEnvelopes( return { envelope: Buffer.from(envelope), reducedEnvelope: Buffer.from(reducedEnvelope) }; } +/** + * Parses a decrypted root blob produced by {@link buildVrfKeyEnvelopes}: a CBOR + * envelope `{version: 1, prvKeyShare, vrf}`. Returns the signing and VRF keyshares + * as Buffers. Throws if the blob is not a valid VRF key envelope. + */ +export function parseVrfKeyEnvelopes(decryptedBlob: string): { signing: Buffer; vrf: Buffer } { + let envelope: unknown; + try { + envelope = decode(Buffer.from(decryptedBlob, 'base64')); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + throw new Error(`Failed to decode safe MPC root key envelope: ${message}`); + } + if (!isVrfKeyEnvelope(envelope)) { + throw new Error('Invalid safe MPC root key envelope: expected version, signing keyshare, and VRF keyshare'); + } + const { version, prvKeyShare, vrf } = envelope; + if (version !== VRF_KEY_ENVELOPE_VERSION) { + throw new Error(`Unsupported safe MPC root key envelope version: ${String(version)}`); + } + if (!(prvKeyShare instanceof Uint8Array) || prvKeyShare.length === 0) { + throw new Error('Safe MPC root key envelope is missing a signing keyshare'); + } + if (!(vrf instanceof Uint8Array) || vrf.length === 0) { + throw new Error('Safe MPC root key envelope is missing a VRF keyshare'); + } + return { signing: Buffer.from(prvKeyShare), vrf: Buffer.from(vrf) }; +} + +/** + * Encodes a hardened derivation index as the byte path the DKLS hard-derive wasm + * expects: one big-endian u32 with the hardened bit (0x80000000) set — i.e. the + * child path `m/'`. The server derives the same hardened path from the + * `derivationIndex` it receives on round 1. + */ +export function hardenedDerivationPath(index: number): Uint8Array { + if (!Number.isInteger(index) || index < 0 || index > 0x7fffffff) { + throw new Error(`Invalid derivation index: ${index}`); + } + return new Uint8Array([0x80 | (index >>> 24), (index >>> 16) & 0xff, (index >>> 8) & 0xff, index & 0xff]); +} + /** * EcdsaMPCv2Utils variant that runs the Ristretto VRF DKG alongside the signing DKLS * DKG inside the same MPCv2 keygen rounds, for safe MPC root creation. @@ -105,7 +168,7 @@ export class EcdsaVrfMPCv2Utils extends EcdsaMPCv2Utils { // Tags the resulting user/backup/bitgo root keys with this safe. safeId: string; }): Promise { - const { userSession, backupSession } = this.getUserAndBackupSessions(params.retrofit); + const { userSession, backupSession } = this.createSigningDkgSessions(params.retrofit); const userVrfSession = new DklsVrf.VrfDkg(3, 2, MPCv2PartiesEnum.USER); const backupVrfSession = new DklsVrf.VrfDkg(3, 2, MPCv2PartiesEnum.BACKUP); @@ -161,7 +224,7 @@ export class EcdsaVrfMPCv2Utils extends EcdsaMPCv2Utils { const backupGpgPublicKey = backupGpgKey.publicKey; assert(NonEmptyString.is(userGpgPublicKey), 'User GPG public key is required'); assert(NonEmptyString.is(backupGpgPublicKey), 'Backup GPG public key is required'); - // The platform derives withVrf from the safeId on this request; the VRF messages ride as opaque blobs. + // Keep VRF messages opaque on the key-generation request. const round1Sender = KeyGenSenderForEnterprise( this.bitgo, params.enterprise, @@ -527,8 +590,220 @@ export class EcdsaVrfMPCv2Utils extends EcdsaMPCv2Utils { encryptionSession?.destroy(); } } + /** + * Sends round 1 for the user/BitGo hard-derive ceremony. + */ + async sendDerivationRound1BySender( + senderFn: EcdsaMPCv2DeriveKeySendFn, + userGpgPublicKey: string, + payload: DklsTypes.AuthEncMessages, + parentKeyId: string, + derivationIndex: number + ): Promise { + assert(NonEmptyString.is(userGpgPublicKey), 'User GPG public key is required'); + assert(NonEmptyString.is(parentKeyId), 'Parent key id is required'); + const userMsg1 = payload.broadcastMessages.find((m) => m.from === MPCv2PartiesEnum.USER)?.payload; + assert(userMsg1, 'User message 1 not found in broadcast messages'); + + assert( + MPCv2DeriveRound1Request.props.derivationIndex.is(derivationIndex), + 'Derivation index must be a non-negative safe integer' + ); + const request: MPCv2DeriveRound1Request = { + userGpgPublicKey, + userMsg1: { from: MPCv2PartiesEnum.USER, ...userMsg1 }, + parentKeyId, + derivationIndex, + }; + return senderFn(MPCv2KeyGenStateEnum['MPCv2Derive-R1'], request); + } + + /** + * Sends round 2 for the user/BitGo hard-derive ceremony. + */ + async sendDerivationRound2BySender( + senderFn: EcdsaMPCv2DeriveKeySendFn, + sessionId: string, + payload: DklsTypes.AuthEncMessages + ): Promise { + assert(NonEmptyString.is(sessionId), 'Session ID is required'); + const userMsg2 = payload.broadcastMessages.find((m) => m.from === MPCv2PartiesEnum.USER)?.payload; + assert(userMsg2, 'User message 2 not found in broadcast messages'); + + const request: MPCv2DeriveRound2Request = { + sessionId, + userMsg2: { from: MPCv2PartiesEnum.USER, ...userMsg2 }, + }; + return senderFn(MPCv2KeyGenStateEnum['MPCv2Derive-R2'], request); + } + + /** + * Completes round 3 and returns the child common keychain. + */ + async sendDerivationRound3BySender( + senderFn: EcdsaMPCv2DeriveKeySendFn, + sessionId: string + ): Promise { + assert(NonEmptyString.is(sessionId), 'Session ID is required'); + return senderFn(MPCv2KeyGenStateEnum['MPCv2Derive-R3'], { sessionId }); + } + + /** + * Runs the safe child hard-derive ceremony and registers child key documents. + */ + async createSafeChildKeychains(params: { + passphrase: string; + enterprise: string; + safeId: string; + /** Root key ID used for the derive. */ + parentKeyId: string; + derivationIndex: number; + userRootKeyId: string; + backupRootKeyId: string; + userRootKeyShare: Buffer; + userRootVrfKeyShare: Buffer; + originalPasscodeEncryptionCode?: string; + webauthnInfo?: WebauthnKeyEncryptionInfo; + encryptionVersion?: EncryptionVersion; + }): Promise> { + const userGpgKey = await generateGPGKeyPair('secp256k1'); + + const { mpcv2PublicKey } = await this.getBitgoGpgPubkeyBasedOnFeatureFlags(params.enterprise, true); + const mpcv2Key = mpcv2PublicKey ?? this.bitgoMPCv2PublicGpgKey; + assert(mpcv2Key, 'Failed to get BitGo MPCv2 GPG public key'); + const bitgoPublicGpgKey = mpcv2Key.armor(); + + if (envRequiresBitgoPubGpgKeyConfig(this.bitgo.getEnv())) { + assert(isBitgoMpcPubKey(bitgoPublicGpgKey, 'mpcv2'), 'Invalid BitGo GPG public key'); + } + + const userGpgPrvKey: DklsTypes.PartyGpgKey = { + partyId: MPCv2PartiesEnum.USER, + gpgKey: userGpgKey.privateKey, + }; + const bitgoGpgPubKey: DklsTypes.PartyGpgKey = { + partyId: MPCv2PartiesEnum.BITGO, + gpgKey: bitgoPublicGpgKey, + }; + + const path = hardenedDerivationPath(params.derivationIndex); + const userDeriveSession = new DklsDrv.Derive( + 3, + 2, + MPCv2PartiesEnum.USER, + params.userRootKeyShare, + params.userRootVrfKeyShare, + path + ); + + // Round 1: send the user's first message. + const userRound1Msg = await userDeriveSession.initDerive(); + const round1Messages = await DklsComms.encryptAndAuthOutgoingMessages( + { + broadcastMessages: [DklsTypes.serializeBroadcastMessage(userRound1Msg)], + p2pMessages: [], + }, + [bitgoGpgPubKey], + [userGpgPrvKey] + ); + assert(NonEmptyString.is(userGpgKey.publicKey), 'User GPG public key is required'); + const { sessionId, bitgoMsg1 } = await this.sendDerivationRound1BySender( + KeyGenSenderForSafeChild(this.bitgo, params.enterprise, params.safeId), + userGpgKey.publicKey, + round1Messages, + params.parentKeyId, + params.derivationIndex + ); + + // Round 2: process the peer's first message and return the user's second. + const decryptedBitgoRound1 = await DklsComms.decryptAndVerifyIncomingMessages( + { p2pMessages: [], broadcastMessages: [this.formatBitgoBroadcastMessage(bitgoMsg1)] }, + [bitgoGpgPubKey], + [] + ); + const bitgoRound1Msg = decryptedBitgoRound1.broadcastMessages.find((m) => m.from === MPCv2PartiesEnum.BITGO); + assert(bitgoRound1Msg, 'BitGo derive message 1 not found in broadcast messages'); + const userRound2Messages = userDeriveSession.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [DklsTypes.deserializeBroadcastMessage(bitgoRound1Msg)], + }); + const round2Messages = await DklsComms.encryptAndAuthOutgoingMessages( + DklsTypes.serializeMessages(userRound2Messages), + [bitgoGpgPubKey], + [userGpgPrvKey] + ); + const { sessionId: sessionIdRound2, bitgoMsg2 } = await this.sendDerivationRound2BySender( + KeyGenSenderForSafeChild(this.bitgo, params.enterprise, params.safeId), + sessionId, + round2Messages + ); + assert.equal(sessionId, sessionIdRound2, 'Round 1 and 2 Session IDs do not match'); + + // Process the peer response to finalize the local child share. + const decryptedBitgoRound2 = await DklsComms.decryptAndVerifyIncomingMessages( + { p2pMessages: [], broadcastMessages: [this.formatBitgoBroadcastMessage(bitgoMsg2)] }, + [bitgoGpgPubKey], + [] + ); + const bitgoRound2Msg = decryptedBitgoRound2.broadcastMessages.find((m) => m.from === MPCv2PartiesEnum.BITGO); + assert(bitgoRound2Msg, 'BitGo derive message 2 not found in broadcast messages'); + userDeriveSession.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [DklsTypes.deserializeBroadcastMessage(bitgoRound2Msg)], + }); + + // Round 3: obtain the child common-keychain metadata. + const { sessionId: sessionIdRound3, commonKeychain } = await this.sendDerivationRound3BySender( + KeyGenSenderForSafeChild(this.bitgo, params.enterprise, params.safeId), + sessionId + ); + assert.equal(sessionId, sessionIdRound3, 'Round 1 and 3 Session IDs do not match'); + + const userPrivateMaterial = userDeriveSession.getKeyShare(); + const userReducedPrivateMaterial = userDeriveSession.getReducedKeyShare(); + const userCommonKeychain = DklsTypes.getCommonKeychain(userPrivateMaterial); + assert.equal(commonKeychain, userCommonKeychain, 'User and BitGo common keychains do not match'); + + const encryptionSession = + params.encryptionVersion === 2 ? await this.bitgo.createEncryptionSession(params.passphrase) : undefined; + try { + const userKeychainPromise = this.createParticipantKeychain( + MPCv2PartiesEnum.USER, + commonKeychain, + userPrivateMaterial, + userReducedPrivateMaterial, + params.passphrase, + params.originalPasscodeEncryptionCode, + params.webauthnInfo, + encryptionSession, + params.encryptionVersion, + params.enterprise, + params.safeId, + { parentKeyId: params.userRootKeyId, index: params.derivationIndex } + ); + const backupKeychainPromise = this.createParticipantKeychain( + MPCv2PartiesEnum.BACKUP, + commonKeychain, + undefined, + undefined, + params.passphrase, + params.originalPasscodeEncryptionCode, + undefined, + encryptionSession, + params.encryptionVersion, + undefined, + params.safeId, + { parentKeyId: params.backupRootKeyId, index: params.derivationIndex } + ); + + const [userKeychain, backupKeychain] = await Promise.all([userKeychainPromise, backupKeychainPromise]); + return { userKeychain, backupKeychain }; + } finally { + encryptionSession?.destroy(); + } + } - private getUserAndBackupSessions(retrofit?: DecryptedRetrofitPayload) { + private createSigningDkgSessions(retrofit?: DecryptedRetrofitPayload) { if (retrofit) { const retrofitData = this.getMpcV2RetrofitDataFromMpcV1Keys({ mpcv1UserKeyShare: retrofit.decryptedUserKey, diff --git a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/typesMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/typesMPCv2.ts index 57b0938aa6..db7d9f0191 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/typesMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/typesMPCv2.ts @@ -1,5 +1,11 @@ import * as t from 'io-ts'; import { + MPCv2DeriveRound1Request, + MPCv2DeriveRound1Response, + MPCv2DeriveRound2Request, + MPCv2DeriveRound2Response, + MPCv2DeriveRound3Request, + MPCv2DeriveRound3Response, MPCv2KeyGenRound1Request, MPCv2KeyGenRound1Response, MPCv2KeyGenRound2Request, @@ -47,3 +53,13 @@ export type GenerateMPCv2KeyRequestBody = t.TypeOf & MpcV2VrfKeyGenResponseFields; + +export type GenerateMPCv2DeriveKeyRequest = + | MPCv2DeriveRound1Request + | MPCv2DeriveRound2Request + | MPCv2DeriveRound3Request; + +export type GenerateMPCv2DeriveKeyRequestResponse = + | MPCv2DeriveRound1Response + | MPCv2DeriveRound2Response + | MPCv2DeriveRound3Response; diff --git a/modules/sdk-core/test/unit/bitgo/safe/safe.ts b/modules/sdk-core/test/unit/bitgo/safe/safe.ts index 94aedde4c3..4b983b0436 100644 --- a/modules/sdk-core/test/unit/bitgo/safe/safe.ts +++ b/modules/sdk-core/test/unit/bitgo/safe/safe.ts @@ -1,7 +1,7 @@ import * as sinon from 'sinon'; import 'should'; import { SafeData } from '@bitgo/public-types'; -import { IncorrectPasswordError, Safe, deriveSafeChildHardenedFromXprv } from '../../../../src'; +import { ECDSAUtils, IncorrectPasswordError, Safe, deriveSafeChildHardenedFromXprv } from '../../../../src'; const ROOT_XPRV = 'xprv9s21ZrQH143K3hekyNj7TciR4XNYe1kMj68W2ipjJGNHETWP7o42AjDnSPgKhdZ4x8NBAvaL72RrXjuXNdmkMqLERZza73oYugGtbLFXG8g'; @@ -108,21 +108,23 @@ describe('Safe', function () { }); }); - describe('member/share methods are stubbed (WCN-1204)', function () { - it('addMember throws not-implemented (WCN-1204)', async function () { - await safe.addMember({ userId: 'u', permissions: ['view'] }).should.be.rejectedWith(/WCN-1204/); + describe('member/share methods are stubbed', function () { + it('addMember throws not-implemented', async function () { + await safe.addMember({ userId: 'u', permissions: ['view'] }).should.be.rejectedWith(/not yet implemented/); }); - it('addMemberToWallet throws not-implemented (WCN-1204)', async function () { - await safe.addMemberToWallet({ walletId: 'w', walletPassphrase: 'p' }).should.be.rejectedWith(/WCN-1204/); + it('addMemberToWallet throws not-implemented', async function () { + await safe + .addMemberToWallet({ walletId: 'w', walletPassphrase: 'p' }) + .should.be.rejectedWith(/not yet implemented/); }); - it('listShares throws not-implemented (WCN-1204)', async function () { - await safe.listShares().should.be.rejectedWith(/WCN-1204/); + it('listShares throws not-implemented', async function () { + await safe.listShares().should.be.rejectedWith(/not yet implemented/); }); - it('acceptShare throws not-implemented (WCN-1204)', async function () { - await safe.acceptShare({ safeShareId: 's' }).should.be.rejectedWith(/WCN-1204/); + it('acceptShare throws not-implemented', async function () { + await safe.acceptShare({ safeShareId: 's' }).should.be.rejectedWith(/not yet implemented/); }); }); @@ -150,7 +152,6 @@ describe('Safe', function () { keychains: sinon.stub().returns({ get: keychainsGet, add: keychainsAdd }), }); } - beforeEach(function () { stubCoin('tbtc'); mockBitGo.decrypt = sinon.stub().callsFake(({ input, password }: { input: string; password: string }) => { @@ -229,17 +230,54 @@ describe('Safe', function () { addArgs.should.not.have.property('derivedFromParentWithSeed'); }); - it('rejects TSS minting', async function () { - await safe - .createWallet({ coin: 'hteth', label: 'evm', passphrase: 'pw', multisigType: 'tss' }) - .should.be.rejectedWith(/MPC safe wallet minting is not yet implemented/); - }); - - it('rejects a TSS-default coin even without multisigType tss', async function () { + it('mints a TSS wallet via the user/BitGo derive ceremony', async function () { stubCoin('hteth', { getDefaultMultisigType: 'tss' }); - await safe - .createWallet({ coin: 'hteth', label: 'evm', passphrase: 'pw' }) - .should.be.rejectedWith(/MPC safe wallet minting is not yet implemented/); + // Real VRF key envelope: `{version: 1, prvKeyShare, vrf}`. + const userBlob = ECDSAUtils.buildVrfKeyEnvelopes( + Buffer.from('signing-1'), + Buffer.from('reduced-1'), + Buffer.from('vrf-1') + ).envelope.toString('base64'); + keychainsGet.onFirstCall().resolves({ id: 'ecdsa-user', source: 'user', encryptedPrv: `enc:${userBlob}` }); + mockBitGo.decrypt = sinon + .stub() + .callsFake(({ input }: { input: string }) => Promise.resolve(input.startsWith('enc:') ? input.slice(4) : '')); + + derivationQuery.returns({ + result: sinon.stub().resolves({ slot: 'ecdsaMpc', index: 0 }), + }); + + const ceremonyStub = sinon.stub(ECDSAUtils.EcdsaVrfMPCv2Utils.prototype, 'createSafeChildKeychains').resolves({ + userKeychain: { id: 'ecdsa-child-user' }, + backupKeychain: { id: 'ecdsa-child-backup-placeholder' }, + } as never); + + const wallet = await safe.createWallet({ coin: 'hteth', label: 'evm', passphrase: 'pw', multisigType: 'tss' }); + + derivationQuery.calledOnceWithExactly({ slot: 'ecdsaMpc' }).should.be.true(); + keychainsGet.calledOnceWithExactly({ id: 'ecdsa-user' }).should.be.true(); + const ceremonyArgs = ceremonyStub.firstCall.args[0]; + ceremonyArgs.should.containEql({ + safeId: 'test-safe-id', + enterprise: 'test-enterprise-id', + parentKeyId: 'ecdsa-bitgo', + derivationIndex: 0, + userRootKeyId: 'ecdsa-user', + backupRootKeyId: 'ecdsa-backup', + }); + ceremonyArgs.should.not.have.property('backupRootKeyShare'); + ceremonyArgs.should.not.have.property('backupRootVrfKeyShare'); + ceremonyArgs.userRootKeyShare.should.deepEqual(Buffer.from('signing-1')); + ceremonyArgs.userRootVrfKeyShare.should.deepEqual(Buffer.from('vrf-1')); + + mintSend.firstCall.args[0].should.eql({ + coin: 'hteth', + label: 'evm', + type: 'hot', + multisigType: 'tss', + keys: ['ecdsa-child-user', 'ecdsa-child-backup-placeholder'], + }); + wallet.id().should.equal('wallet-id'); }); it('rejects a peeked derivation index for the wrong slot', async function () { diff --git a/modules/sdk-lib-mpc/src/tss/dkls-vrf/util.ts b/modules/sdk-lib-mpc/src/tss/dkls-vrf/util.ts index 75f35c236d..7e5d3da6e2 100644 --- a/modules/sdk-lib-mpc/src/tss/dkls-vrf/util.ts +++ b/modules/sdk-lib-mpc/src/tss/dkls-vrf/util.ts @@ -1,4 +1,5 @@ import { Buffer } from 'buffer'; +import { Derive } from '../ecdsa-dkls/derive'; import { VrfDkg } from './dkg'; /** @@ -53,3 +54,77 @@ export async function generateVrfDKGKeyShares( }); return [user, backup, bitgo]; } + +/** + * Runs the hard-derivation ceremony on top of completed root sessions: the user (0) + * hard-derive session pairs with a fresh BitGo (2) session, and the backup (1) + * session with a second fresh BitGo session. The hard-derive wasm session is a + * 2-party threshold protocol per instance, so the BitGo server side runs two + * sessions — one per SDK party. All four derived keyshares agree on the child + * public key; the two BitGo shares are distinct private shares of that same key. + * + * @param path hardened derivation path bytes (e.g. `m/0'` as a 4-byte big-endian + * index with the hardened bit set) + * @returns [user, backup, bitgoUserPair, bitgoBackupPair] completed Derive sessions + */ +export async function generateHardDerivedKeyShares( + userRoot: { getKeyShare(): Buffer }, + backupRoot: { getKeyShare(): Buffer }, + bitgoRoot: { getKeyShare(): Buffer }, + userVrf: VrfDkg, + backupVrf: VrfDkg, + bitgoVrf: VrfDkg, + path: Uint8Array, + seedUser?: Buffer, + seedBackup?: Buffer +): Promise<[Derive, Derive, Derive, Derive]> { + const user = new Derive(3, 2, 0, userRoot.getKeyShare(), userVrf.getKeyShare(), path, seedUser); + const backup = new Derive(3, 2, 1, backupRoot.getKeyShare(), backupVrf.getKeyShare(), path, seedBackup); + const bitgoUserPair = new Derive(3, 2, 2, bitgoRoot.getKeyShare(), bitgoVrf.getKeyShare(), path); + const bitgoBackupPair = new Derive(3, 2, 2, bitgoRoot.getKeyShare(), bitgoVrf.getKeyShare(), path); + + // #region round 1 + const userMsg1 = await user.initDerive(); + const backupMsg1 = await backup.initDerive(); + const bitgoUserPairMsg1 = await bitgoUserPair.initDerive(); + const bitgoBackupPairMsg1 = await bitgoBackupPair.initDerive(); + + const userMsg2 = user.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [{ payload: bitgoUserPairMsg1.payload, from: bitgoUserPairMsg1.from }], + }); + const bitgoUserPairMsg2 = bitgoUserPair.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [{ payload: userMsg1.payload, from: userMsg1.from }], + }); + const backupMsg2 = backup.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [{ payload: bitgoBackupPairMsg1.payload, from: bitgoBackupPairMsg1.from }], + }); + const bitgoBackupPairMsg2 = bitgoBackupPair.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [{ payload: backupMsg1.payload, from: backupMsg1.from }], + }); + // #endregion + + // #region round 2 + user.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [{ payload: bitgoUserPairMsg2.broadcastMessages[0].payload, from: 2 }], + }); + bitgoUserPair.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [{ payload: userMsg2.broadcastMessages[0].payload, from: 0 }], + }); + backup.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [{ payload: bitgoBackupPairMsg2.broadcastMessages[0].payload, from: 2 }], + }); + bitgoBackupPair.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [{ payload: backupMsg2.broadcastMessages[0].payload, from: 1 }], + }); + // #endregion + + return [user, backup, bitgoUserPair, bitgoBackupPair]; +} diff --git a/modules/sdk-lib-mpc/src/tss/ecdsa-dkls/derive.ts b/modules/sdk-lib-mpc/src/tss/ecdsa-dkls/derive.ts new file mode 100644 index 0000000000..e8e3a176d9 --- /dev/null +++ b/modules/sdk-lib-mpc/src/tss/ecdsa-dkls/derive.ts @@ -0,0 +1,368 @@ +import type { + HardDeriveSession as DklsHardDeriveSession, + Message as VrfWasmMessage, + VrfKeygenSession as DklsVrfKeygenSession, +} from '@silencelaboratories/dkls-wasm-ll-vrf-node'; +import type { + HardDeriveSession as DklsVrfWebHardDeriveSession, + VrfKeygenSession as DklsVrfWebKeygenSession, +} from '@silencelaboratories/dkls-wasm-ll-vrf-web'; +import { decode, encode } from 'cbor-x'; +import { Buffer } from 'buffer'; +import { DeserializedBroadcastMessage, DeserializedMessages, ReducedKeyShare } from './types'; + +// Platform-specific modules that do not exist everywhere: the node/web/bundler wasm +// variants are mutually exclusive and selected at runtime (node process vs browser vs +// bundler). Static imports would load the wrong platform's wasm binding, so both the +// type aliases below and the lazy `await import()` calls are deliberate. +type NodeVrfWasmer = typeof import('@silencelaboratories/dkls-wasm-ll-vrf-node'); +type WebVrfWasmer = typeof import('@silencelaboratories/dkls-wasm-ll-vrf-web'); +type BundlerVrfWasmer = typeof import('@silencelaboratories/dkls-wasm-ll-vrf-bundler'); + +type VrfWasm = NodeVrfWasmer | WebVrfWasmer | BundlerVrfWasmer; +const DERIVE_SEED_LENGTH = 32; + +export type { DklsHardDeriveSession, DklsVrfWebHardDeriveSession, DklsVrfKeygenSession, DklsVrfWebKeygenSession }; + +export enum DeriveState { + Uninitialized, + Round1, + Round2, + Complete, + InvalidState, +} + +export interface DeriveSessionData { + deriveSessionBytes: Uint8Array; + deriveState: DeriveState; + keyShareBuff?: Buffer; + // This party's own protocol messages, re-fed into the session on the next round + // because the wasm session validates the sender set against {self, partner}. + ownMsg1?: Uint8Array; + ownMsg2?: Uint8Array; +} + +/** + * Round driver for the DKLS23 hard-derivation protocol (Ristretto VRF backed), + * which derives a child signing keyshare from a root keyshare and its VRF keyshare. + * + * Each session is a 2-party threshold protocol between this party and one partner: + * every round accepts exactly two broadcast messages — this party's own (created by + * `initDerive()` and re-fed automatically) and the partner's. A ceremony runs one + * `Derive` per SDK party, each paired with the BitGo party: + * + * - Round 1 (`WaitMsg1`): consume `{own msg1, partner msg1}`, emit own broadcast msg2. + * - Round 2 (`WaitMsg2`): consume `{own msg2, partner msg2}`, finalize the session + * and extract the derived DKLS `Keyshare`. + * + * The session is seeded from the decrypted root blob: the DKLS signing keyshare and + * the Ristretto VRF keyshare produced by root keygen, plus the child's hardened + * derivation path. + * + * Party indices follow the MPCv2 convention: 0 = user, 1 = backup, 2 = bitgo. + */ +export class Derive { + protected deriveSession: DklsHardDeriveSession | DklsVrfWebHardDeriveSession | undefined; + protected deriveSessionBytes: Uint8Array; + protected keyShareBuff: Buffer | undefined; + protected n: number; + protected t: number; + protected partyIdx: number; + protected rootKeyShare: Buffer; + protected vrfKeyShare: Buffer; + protected path: Uint8Array; + protected seed: Buffer | undefined; + protected deriveState: DeriveState = DeriveState.Uninitialized; + protected vrfWasm: VrfWasm | null; + // This party's own protocol messages, re-fed into the session on the next round: + // the wasm hard-derive session validates the sender set against {self, partner}. + protected ownMsg1: Uint8Array | undefined; + protected ownMsg2: Uint8Array | undefined; + + constructor( + n: number, + t: number, + partyIdx: number, + rootKeyShare: Buffer, + vrfKeyShare: Buffer, + path: Uint8Array, + seed?: Buffer, + vrfWasm?: BundlerVrfWasmer + ) { + this.n = n; + this.t = t; + this.partyIdx = partyIdx; + this.rootKeyShare = rootKeyShare; + this.vrfKeyShare = vrfKeyShare; + this.path = path; + this.seed = seed; + this.vrfWasm = vrfWasm ?? null; + this.deriveSessionBytes = new Uint8Array(0); + } + + private async loadVrfWasm(): Promise { + if (!this.vrfWasm) { + this.vrfWasm = await import('@silencelaboratories/dkls-wasm-ll-vrf-node'); + } + } + + private getVrfWasm() { + if (!this.vrfWasm) { + throw Error('VRF wasm not loaded'); + } + return this.vrfWasm; + } + + private _restoreSession() { + if (!this.deriveSession) { + this.deriveSession = this.getVrfWasm().HardDeriveSession.fromBytes(this.deriveSessionBytes); + } + } + + /** + * Re-derive the round state from the wasm session bytes instead of trusting a + * caller-supplied enum. The wasm embeds the round tag (`WaitMsg1`/`WaitMsg2`, + * then a `Share` payload once finalized) for exactly this reason. + */ + private _deserializeState() { + if (!this.deriveSession) { + throw Error('Session not initialized'); + } + const decoded = decode(this.deriveSession.toBytes()); + const round = decoded?.inner?.round; + if (round === 'WaitMsg1') { + this.deriveState = DeriveState.Round1; + } else if (round === 'WaitMsg2') { + this.deriveState = DeriveState.Round2; + } else if (decoded?.inner?.state?.Share !== undefined || this.deriveSession.isFinished()) { + this.deriveState = DeriveState.Complete; + } else { + this.deriveState = DeriveState.InvalidState; + throw Error(`Invalid State: ${JSON.stringify(round)}`); + } + } + + /** + * Create this party's first hard-derive message (broadcast). Seeds the wasm + * session from the root keyshare, VRF keyshare and derivation path. + */ + async initDerive(): Promise { + if (!this.vrfWasm) { + await this.loadVrfWasm(); + } + if (this.t > this.n || this.partyIdx >= this.n) { + throw Error('Invalid parameters for hard derive'); + } + if (this.deriveState !== DeriveState.Uninitialized) { + throw Error('Hard derive session already initialized'); + } + if (this.seed && this.seed.length !== DERIVE_SEED_LENGTH) { + throw Error(`Seed should be ${DERIVE_SEED_LENGTH} bytes, got ${this.seed.length}.`); + } + if ( + typeof window !== 'undefined' && + /* checks for electron processes */ + !window.process && + !window.process?.['type'] + ) { + /* This is only needed for browsers/web because it uses fetch to resolve the wasm asset for the web */ + const initVrf = await import('@silencelaboratories/dkls-wasm-ll-vrf-web'); + await initVrf.default(); + } + const { HardDeriveSession, Keyshare, VrfKeyshare } = this.getVrfWasm(); + const rootKeyShare = Keyshare.fromBytes(this.rootKeyShare); + if (rootKeyShare.partyId !== this.partyIdx) { + throw Error(`Party index: ${this.partyIdx} does not match root key share partyId: ${rootKeyShare.partyId}`); + } + const vrfKeyShare = VrfKeyshare.fromBytes(this.vrfKeyShare); + if (vrfKeyShare.partyId !== this.partyIdx) { + throw Error(`Party index: ${this.partyIdx} does not match VRF key share partyId: ${vrfKeyShare.partyId}`); + } + this.deriveSession = this.seed + ? new HardDeriveSession(rootKeyShare, vrfKeyShare, this.path, new Uint8Array(this.seed)) + : new HardDeriveSession(rootKeyShare, vrfKeyShare, this.path); + try { + const message = this.deriveSession.createFirstMessage(); + // Copy the payload out before freeing the wasm message object. + const payload = new Uint8Array(message.payload); + const from = message.from_id; + message.free(); + this.ownMsg1 = payload; + this.deriveSessionBytes = this.deriveSession.toBytes(); + this._deserializeState(); + return { payload, from }; + } catch (e) { + throw Error(`Error while creating the first hard-derive message from party ${this.partyIdx}: ${e}`); + } + } + + /** + * Process the messages this party holds for the current round and return this + * party's messages for the next round. Callers pass the partner's broadcast + * message only; this party's own message is re-fed automatically because the + * wasm session validates the sender set as `{self, partner}`. + * + * - Round 1 (WaitMsg1): consumes `{own msg1, partner msg1}` and emits this + * party's broadcast msg2. + * - Round 2 (WaitMsg2): consumes `{own msg2, partner msg2}` and finalizes the + * session, returning no messages. + */ + handleIncomingMessages(messagesForIthRound: DeserializedMessages): DeserializedMessages { + this._restoreSession(); + if (!this.deriveSession) { + throw Error('Session not initialized'); + } + const { Message } = this.getVrfWasm(); + let nextRoundMessages: VrfWasmMessage[] = []; + const nextRoundDeserializedMessages: DeserializedMessages = { broadcastMessages: [], p2pMessages: [] }; + try { + switch (this.deriveState) { + case DeriveState.Round1: { + const partnerMessages = messagesForIthRound.broadcastMessages.filter((m) => m.from !== this.partyIdx); + if (partnerMessages.length !== 1 || !this.ownMsg1) { + throw Error('Expected exactly one broadcast message from the derive partner in round 1'); + } + nextRoundMessages = this.deriveSession.handleMessages([ + new Message(this.ownMsg1, this.partyIdx), + new Message(partnerMessages[0].payload, partnerMessages[0].from), + ]); + this._deserializeState(); + break; + } + case DeriveState.Round2: { + const partnerMessages = messagesForIthRound.broadcastMessages.filter((m) => m.from !== this.partyIdx); + if (partnerMessages.length !== 1 || !this.ownMsg2) { + throw Error('Expected exactly one broadcast message from the derive partner in round 2'); + } + nextRoundMessages = this.deriveSession.handleMessages([ + new Message(this.ownMsg2, this.partyIdx), + new Message(partnerMessages[0].payload, partnerMessages[0].from), + ]); + // handleMessages() consumes the session; keyshare() extracts the derived share. + const keyShare = this.deriveSession.keyshare(); + this.keyShareBuff = Buffer.from(keyShare.toBytes()); + keyShare.free(); + this.deriveState = DeriveState.Complete; + return nextRoundDeserializedMessages; + } + default: + throw Error(`Invalid hard-derive state: ${this.deriveState}`); + } + + nextRoundDeserializedMessages.broadcastMessages = nextRoundMessages + .filter((m) => m.to_id === undefined) + .map((m) => ({ payload: new Uint8Array(m.payload), from: m.from_id })); + nextRoundDeserializedMessages.p2pMessages = nextRoundMessages + .filter((m): m is VrfWasmMessage & { to_id: number } => m.to_id !== undefined) + .map((m) => ({ payload: new Uint8Array(m.payload), from: m.from_id, to: m.to_id })); + // The round-1 output is this party's msg2, re-fed into the session on round 2. + const ownMsg2 = nextRoundDeserializedMessages.broadcastMessages.find((m) => m.from === this.partyIdx); + if (ownMsg2) { + this.ownMsg2 = ownMsg2.payload; + } + return nextRoundDeserializedMessages; + } catch (e) { + throw Error( + `Error while creating hard-derive messages from party ${this.partyIdx}, state ${this.deriveState}: ${e}` + ); + } finally { + nextRoundMessages.forEach((m) => m.free()); + // keyshare() consumed (and deallocated) the session on round 2; only persist + // mid-protocol session bytes while the session object still exists. + if (this.deriveState !== DeriveState.Complete && this.deriveSession) { + this.deriveSessionBytes = this.deriveSession.toBytes(); + } + this.deriveSession = undefined; + } + } + + /** + * Get the derived DKLS keyshare bytes (CBOR `Keyshare`) once the derive is + * complete. This buffer is private key material. + */ + getKeyShare(): Buffer { + if (!this.keyShareBuff) { + throw Error('Can not get key share, hard derive is not complete yet.'); + } + return this.keyShareBuff; + } + + /** + * Returns a CBOR-encoded ReducedKeyShare buffer containing the derived party's + * private scalar (s_i) in the `prv` field. This buffer is private key material; + * the caller encrypts it as `reducedEncryptedPrv`, matching `Dkg.getReducedKeyShare`. + */ + getReducedKeyShare(): Buffer { + if (!this.keyShareBuff) { + throw Error('Can not get key share, hard derive is not complete yet.'); + } + const decodedKeyshare = decode(this.keyShareBuff); + const reducedKeyShare: ReducedKeyShare = { + bigSList: decodedKeyshare.big_s_list, + xList: decodedKeyshare.x_i_list, + rootChainCode: decodedKeyshare.root_chain_code, + prv: decodedKeyshare.s_i, + pub: decodedKeyshare.public_key, + }; + return Buffer.from(encode(reducedKeyShare)); + } + + /** + * Get the current session data that can be used to restore the session later. + * + * The returned session bytes are secret key material — they carry this party's + * root keyshares. They must never be logged or persisted in the clear; the + * caller encrypts them exactly like the key share itself. + */ + getSessionData(): DeriveSessionData { + const sessionData: DeriveSessionData = { + deriveSessionBytes: this.deriveSessionBytes, + deriveState: this.deriveState, + }; + if (this.keyShareBuff) { + sessionData.keyShareBuff = this.keyShareBuff; + } + if (this.ownMsg1) { + sessionData.ownMsg1 = this.ownMsg1; + } + if (this.ownMsg2) { + sessionData.ownMsg2 = this.ownMsg2; + } + return sessionData; + } + + /** + * Restore a session snapshot captured after initDerive() or round 1 processing. + * Do not call this before initDerive() has created session bytes. + * The round state is re-derived from the wasm session bytes, not the caller enum. + */ + static async restoreSession( + n: number, + t: number, + partyIdx: number, + rootKeyShare: Buffer, + vrfKeyShare: Buffer, + path: Uint8Array, + sessionData: DeriveSessionData, + seed?: Buffer, + vrfWasm?: BundlerVrfWasmer + ): Promise { + const derive = new Derive(n, t, partyIdx, rootKeyShare, vrfKeyShare, path, seed, vrfWasm); + if (!derive.vrfWasm) { + await derive.loadVrfWasm(); + } + derive.deriveSessionBytes = sessionData.deriveSessionBytes; + if (sessionData.keyShareBuff) { + derive.keyShareBuff = sessionData.keyShareBuff; + } + if (sessionData.ownMsg1) { + derive.ownMsg1 = sessionData.ownMsg1; + } + if (sessionData.ownMsg2) { + derive.ownMsg2 = sessionData.ownMsg2; + } + derive._restoreSession(); + derive._deserializeState(); + return derive; + } +} diff --git a/modules/sdk-lib-mpc/src/tss/ecdsa-dkls/index.ts b/modules/sdk-lib-mpc/src/tss/ecdsa-dkls/index.ts index 3f311a4e8a..c5510ae4a6 100644 --- a/modules/sdk-lib-mpc/src/tss/ecdsa-dkls/index.ts +++ b/modules/sdk-lib-mpc/src/tss/ecdsa-dkls/index.ts @@ -1,4 +1,5 @@ export * as DklsDkg from './dkg'; +export * as DklsDrv from './derive'; export * as DklsDsg from './dsg'; export * as DklsTypes from './types'; export * as DklsComms from './commsLayer'; diff --git a/modules/sdk-lib-mpc/test/unit/tss/dkls-vrf/derive.ts b/modules/sdk-lib-mpc/test/unit/tss/dkls-vrf/derive.ts new file mode 100644 index 0000000000..3f5549fd11 --- /dev/null +++ b/modules/sdk-lib-mpc/test/unit/tss/dkls-vrf/derive.ts @@ -0,0 +1,207 @@ +import assert from 'assert'; +import { decode } from 'cbor-x'; +import { DklsTypes, DklsUtils, DklsVrfUtils } from '../../../../src/tss'; +import { DklsDrv } from '../../../../src/tss/ecdsa-dkls'; +import { DeriveState } from '../../../../src/tss/ecdsa-dkls/derive'; + +// Hardened child path `m/0'` as a single big-endian u32 with the hardened bit set. +const PATH_M0 = new Uint8Array([0x80, 0x00, 0x00, 0x00]); + +describe('DKLS hard derive (VRF backed)', function () { + it('should derive a child keyshare for all parties, agreeing on the child public key', async function () { + const [userRoot, backupRoot, bitgoRoot] = await DklsUtils.generateDKGKeyShares(); + const [vrfUser, vrfBackup, vrfBitgo] = await DklsVrfUtils.generateVrfDKGKeyShares(); + const rootCommonKeychain = DklsTypes.getCommonKeychain(userRoot.getKeyShare()); + assert.equal(rootCommonKeychain, DklsTypes.getCommonKeychain(backupRoot.getKeyShare())); + + const [user, backup, bitgoUserPair, bitgoBackupPair] = await DklsVrfUtils.generateHardDerivedKeyShares( + userRoot, + backupRoot, + bitgoRoot, + vrfUser, + vrfBackup, + vrfBitgo, + PATH_M0 + ); + + const userChild = decode(user.getKeyShare()); + const backupChild = decode(backup.getKeyShare()); + const bitgoUserChild = decode(bitgoUserPair.getKeyShare()); + const bitgoBackupChild = decode(bitgoBackupPair.getKeyShare()); + + // All four derived keyshares carry the child common keychain. + const userChildKeychain = DklsTypes.getCommonKeychain(user.getKeyShare()); + const backupChildKeychain = DklsTypes.getCommonKeychain(backup.getKeyShare()); + const bitgoUserChildKeychain = DklsTypes.getCommonKeychain(bitgoUserPair.getKeyShare()); + const bitgoBackupChildKeychain = DklsTypes.getCommonKeychain(bitgoBackupPair.getKeyShare()); + assert.equal(userChildKeychain, backupChildKeychain); + assert.equal(userChildKeychain, bitgoUserChildKeychain); + assert.equal(userChildKeychain, bitgoBackupChildKeychain); + // The child common keychain differs from the root: the hard-derive tweak moved the key. + assert.notEqual(userChildKeychain, rootCommonKeychain); + + // All parties keep their identities and agree on the child public key. + assert.equal(userChild.party_id, 0); + assert.equal(backupChild.party_id, 1); + assert.equal(bitgoUserChild.party_id, 2); + assert.equal(bitgoBackupChild.party_id, 2); + assert.equal( + Buffer.from(userChild.public_key).toString('hex'), + Buffer.from(backupChild.public_key).toString('hex') + ); + assert.equal( + Buffer.from(bitgoUserChild.public_key).toString('hex'), + Buffer.from(bitgoBackupChild.public_key).toString('hex') + ); + // Private shares differ per party — the two BitGo sessions are distinct shares of the same child key. + assert.notDeepStrictEqual(userChild.s_i, backupChild.s_i); + assert.notDeepStrictEqual(bitgoUserChild.s_i, bitgoBackupChild.s_i); + assert.notDeepStrictEqual(bitgoUserChild.s_i, userChild.s_i); + + // Child keyshares are ordinary DKLS Keyshares (signing material only). + assert.deepEqual( + Object.keys(userChild).sort(), + [ + 'big_s_list', + 'final_session_id', + 'party_id', + 'public_key', + 'rank_list', + 'rec_seed_list', + 'root_chain_code', + 's_i', + 'seed_ot_receivers', + 'seed_ot_senders', + 'sent_seed_list', + 'threshold', + 'total_parties', + 'x_i_list', + ].sort() + ); + }); + + it('should produce a deterministic child public key with fixed root seeds', async function () { + const seedUser = Buffer.from('a304733c16cc821fe171d5c7dbd7276fd90deae808b7553d17a1e55e4a76b270', 'hex'); + const seedBackup = Buffer.from('9d91c2e6353202cf61f8f275158b3468e9a00f7872fc2fd310b72cd026e2e2f9', 'hex'); + const seedBitgo = Buffer.from('33c749b635cdba7f9fbf51ad0387431cde47e20d8dc13acd1f51a9a0ad06ebfe', 'hex'); + const [userRoot, backupRoot, bitgoRoot] = await DklsUtils.generateDKGKeyShares( + undefined, + undefined, + undefined, + seedUser, + seedBackup, + seedBitgo + ); + const [vrfUser, vrfBackup, vrfBitgo] = await DklsVrfUtils.generateVrfDKGKeyShares(seedUser, seedBackup, seedBitgo); + + const [user, , bitgoUserPair] = await DklsVrfUtils.generateHardDerivedKeyShares( + userRoot, + backupRoot, + bitgoRoot, + vrfUser, + vrfBackup, + vrfBitgo, + PATH_M0, + seedUser, + seedBackup + ); + assert.equal( + DklsTypes.getCommonKeychain(user.getKeyShare()), + DklsTypes.getCommonKeychain(bitgoUserPair.getKeyShare()) + ); + const firstChild = DklsTypes.getCommonKeychain(user.getKeyShare()); + + const [user2] = await DklsVrfUtils.generateHardDerivedKeyShares( + userRoot, + backupRoot, + bitgoRoot, + vrfUser, + vrfBackup, + vrfBitgo, + PATH_M0, + seedUser, + seedBackup + ); + assert.equal(DklsTypes.getCommonKeychain(user2.getKeyShare()), firstChild); + }); + + it('should reject a party index that does not match the root keyshare party id', async function () { + const [userRoot] = await DklsUtils.generateDKGKeyShares(); + const [vrfUser] = await DklsVrfUtils.generateVrfDKGKeyShares(); + const mismatched = new DklsDrv.Derive(3, 2, 1, userRoot.getKeyShare(), vrfUser.getKeyShare(), PATH_M0); + await assert.rejects(() => mismatched.initDerive(), /does not match root key share partyId/); + }); + + it('should expose session data for restore and resume mid-protocol', async function () { + const [userRoot, backupRoot, bitgoRoot] = await DklsUtils.generateDKGKeyShares(); + const [vrfUser, vrfBackup, vrfBitgo] = await DklsVrfUtils.generateVrfDKGKeyShares(); + // Drive the user pair through round 1, snapshot the user session, then resume it + // from the snapshot and finish the ceremony: user(0) <-> bitgoA(2), backup(1) <-> bitgoB(2). + const user = new DklsDrv.Derive(3, 2, 0, userRoot.getKeyShare(), vrfUser.getKeyShare(), PATH_M0); + const backup = new DklsDrv.Derive(3, 2, 1, backupRoot.getKeyShare(), vrfBackup.getKeyShare(), PATH_M0); + const bitgoUserPair = new DklsDrv.Derive(3, 2, 2, bitgoRoot.getKeyShare(), vrfBitgo.getKeyShare(), PATH_M0); + const bitgoBackupPair = new DklsDrv.Derive(3, 2, 2, bitgoRoot.getKeyShare(), vrfBitgo.getKeyShare(), PATH_M0); + const userMsg1 = await user.initDerive(); + const backupMsg1 = await backup.initDerive(); + const bitgoUserPairMsg1 = await bitgoUserPair.initDerive(); + const bitgoBackupPairMsg1 = await bitgoBackupPair.initDerive(); + + const userRound1Snapshot = user.getSessionData(); + assert.equal(userRound1Snapshot.deriveState, DeriveState.Round1); + const userFromRound1 = await DklsDrv.Derive.restoreSession( + 3, + 2, + 0, + userRoot.getKeyShare(), + vrfUser.getKeyShare(), + PATH_M0, + userRound1Snapshot + ); + const userMsg2 = userFromRound1.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [{ payload: bitgoUserPairMsg1.payload, from: 2 }], + }); + const backupMsg2 = backup.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [{ payload: bitgoBackupPairMsg1.payload, from: 2 }], + }); + const bitgoUserPairMsg2 = bitgoUserPair.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [{ payload: userMsg1.payload, from: 0 }], + }); + const bitgoBackupPairMsg2 = bitgoBackupPair.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [{ payload: backupMsg1.payload, from: 1 }], + }); + + const snapshot = userFromRound1.getSessionData(); + assert.equal(snapshot.deriveState, DeriveState.Round2); + const resumed = await DklsDrv.Derive.restoreSession( + 3, + 2, + 0, + userRoot.getKeyShare(), + vrfUser.getKeyShare(), + PATH_M0, + snapshot + ); + resumed.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [{ payload: bitgoUserPairMsg2.broadcastMessages[0].payload, from: 2 }], + }); + backup.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [{ payload: bitgoBackupPairMsg2.broadcastMessages[0].payload, from: 2 }], + }); + bitgoUserPair.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [{ payload: userMsg2.broadcastMessages[0].payload, from: 0 }], + }); + bitgoBackupPair.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [{ payload: backupMsg2.broadcastMessages[0].payload, from: 1 }], + }); + // The resumed-from-snapshot user session agrees with the live backup on the child key. + assert.equal(DklsTypes.getCommonKeychain(resumed.getKeyShare()), DklsTypes.getCommonKeychain(backup.getKeyShare())); + }); +}); diff --git a/yarn.lock b/yarn.lock index 3b67027df7..e2228496c2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1032,6 +1032,17 @@ monocle-ts "^2.3.13" newtype-ts "^0.3.5" +"@bitgo/public-types@6.72.1": + version "6.72.1" + resolved "https://registry.npmjs.org/@bitgo/public-types/-/public-types-6.72.1.tgz#ad8db680222e936ce46641d30581c264c9bc5c1b" + integrity sha512-ePj1wkgAn/C7Zj5+qQagFASVghPt16SIKBy09BnCl++Brl3UVTBPkLG7uhaHY1l4TNBv27sHEQbUg7ifsJ5JOw== + dependencies: + fp-ts "^2.0.0" + io-ts "npm:@bitgo-forks/io-ts@2.1.4" + io-ts-types "^0.5.16" + monocle-ts "^2.3.13" + newtype-ts "^0.3.5" + "@bitgo/wasm-dot@^1.7.0": version "1.7.0" resolved "https://registry.npmjs.org/@bitgo/wasm-dot/-/wasm-dot-1.7.0.tgz"