diff --git a/modules/bitgo/package.json b/modules/bitgo/package.json index 849dd5b49a..748f51908b 100644 --- a/modules/bitgo/package.json +++ b/modules/bitgo/package.json @@ -144,7 +144,7 @@ "superagent": "^9.0.1" }, "devDependencies": { - "@bitgo/public-types": "6.66.0", + "@bitgo/public-types": "6.71.0", "@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..547d85b875 --- /dev/null +++ b/modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaVrfMPCv2/createSafeChildKeychains.ts @@ -0,0 +1,453 @@ +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 BACKUP_ROOT_KEY_ID = 'root-backup-key-id'; +const DERIVATION_INDEX = 0; +const BITGO_ROOT_KEY_ID = 'root-bitgo-key-id'; +// Hardened path `m/0'` as a single big-endian u32 with the hardened bit set. +const PATH_M0 = new Uint8Array([0x80, 0x00, 0x00, 0x00]); + +describe('TSS ECDSA safe child keychains (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 bitGoGgpKey: openpgp.SerializedKeyPair & { + revocationCertificate: string; + }; + let constants: { mpc: { bitgoPublicKey: string; bitgoMPCv2PublicKey: string } }; + let bitgoGpgPrvKey: { partyId: number; gpgKey: string }; + let userGpgPubKey: { partyId: number; gpgKey: string }; + let backupGpgPubKey: { partyId: number; gpgKey: string }; + + before(async function () { + // Allow secp256k1 GPG keys used by these fixtures (the full suite enables this + // globally via sibling test files; set it here so this file also runs in isolation). + openpgp.config.rejectCurves = new Set(); + bitGoGgpKey = await openpgp.generateKey({ + userIDs: [ + { + name: 'bitgo', + email: 'bitgo@test.com', + }, + ], + curve: 'secp256k1', + }); + constants = { + mpc: { + bitgoPublicKey: bitGoGgpKey.publicKey, + bitgoMPCv2PublicKey: bitGoGgpKey.publicKey, + }, + }; + bitgoGpgPrvKey = { + partyId: 2, + gpgKey: bitGoGgpKey.privateKey, + }; + + bitgo = TestBitGo.decorate(BitGo, { env: 'mock' }); + bitgo.initializeTestVars(); + bgUrl = common.Environments[bitgo.getEnv()].uri; + + const baseCoin = bitgo.coin(coinName); + const walletData = { + id: '5b34252f1bf349930e34020a00000000', + enterprise: enterpriseId, + coin: coinName, + coinSpecific: {}, + multisigType: 'tss', + }; + wallet = new Wallet(bitgo, baseCoin, walletData); + tssUtils = new ECDSAUtils.EcdsaVrfMPCv2Utils(bitgo, baseCoin, wallet); + }); + + beforeEach(async function () { + nock.cleanAll(); + await nockGetBitgoPublicKeyBasedOnFeatureFlags(coinName, enterpriseId, bitGoGgpKey); + nock(bgUrl).get('/api/v1/client/constants').times(32).reply(200, { ttl: 3600, constants }); + }); + + after(function () { + nock.cleanAll(); + }); + + it('should derive safe child keychains and register the signing share only', async function () { + const [userRoot, backupRoot, bitgoRoot] = await DklsUtils.generateDKGKeyShares(); + const [vrfUser, vrfBackup, vrfBitgo] = await DklsVrfUtils.generateVrfDKGKeyShares(); + // The server-side BitGo party runs one hard-derive session per SDK party. + 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 round1Nock = await nockDeriveRound1(bitgoUserPair, bitgoBackupPair); + const round2Nock = await nockDeriveRound2(bitgoUserPair, bitgoBackupPair); + const round3Nock = await nockDeriveRound3(bitgoUserPair, bitgoBackupPair); + 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(), + backupRootKeyShare: backupRoot.getKeyShare(), + backupRootVrfKeyShare: vrfBackup.getKeyShare(), + }); + + assert.ok(round1Nock.isDone()); + assert.ok(round2Nock.isDone()); + assert.ok(round3Nock.isDone()); + assert.ok(addKeyNock.isDone()); + + // User and backup children agree on the child common keychain — before any mint. + assert.ok(userKeychain.commonKeychain); + assert.equal(userKeychain.commonKeychain, backupKeychain.commonKeychain); + assert.equal( + userKeychain.commonKeychain, + DklsTypes.getCommonKeychain(bitgoUserPair.getKeyShare()), + 'User and BitGo child common keychains do not match' + ); + assert.equal( + backupKeychain.commonKeychain, + DklsTypes.getCommonKeychain(bitgoBackupPair.getKeyShare()), + 'Backup and BitGo child common keychains do not match' + ); + + // encryptedPrv carries the derived DKLS signing share ONLY — never a VRF + // share. The decrypted content is a plain DKLS Keyshare, not a VRF envelope. + assert.ok(userKeychain.encryptedPrv); + const decryptedUserPrv = await bitgo.decrypt({ input: userKeychain.encryptedPrv, 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); + assert.equal(DklsTypes.getCommonKeychain(Buffer.from(decryptedUserPrv, 'base64')), userKeychain.commonKeychain); + assert.equal(Buffer.from(userChildShare.public_key).toString('hex'), userKeychain.commonKeychain.slice(0, 66)); + + assert.ok(backupKeychain.encryptedPrv); + const decryptedBackupPrv = await bitgo.decrypt({ input: backupKeychain.encryptedPrv, password: 'test' }); + const backupChildShare = decode(Buffer.from(decryptedBackupPrv, 'base64')); + assert.equal(backupChildShare.version, undefined); + assert.equal(backupChildShare.vrf, undefined); + assert.equal(backupChildShare.party_id, 1); + assert.ok(backupChildShare.s_i); + assert.notDeepStrictEqual(backupChildShare.s_i, 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, backupRoot, bitgoRoot] = await DklsUtils.generateDKGKeyShares(); + const [vrfUser, vrfBackup, vrfBitgo] = await DklsVrfUtils.generateVrfDKGKeyShares(); + const bitgoUserPair = new DklsDrv.Derive(3, 2, 2, bitgoRoot.getKeyShare(), vrfBitgo.getKeyShare(), path); + const bitgoBackupPair = new DklsDrv.Derive(3, 2, 2, bitgoRoot.getKeyShare(), vrfBitgo.getKeyShare(), path); + + const round1Nock = await nockDeriveRound1(bitgoUserPair, bitgoBackupPair, 1, idx); + const round2Nock = await nockDeriveRound2(bitgoUserPair, bitgoBackupPair); + const round3Nock = await nockDeriveRound3(bitgoUserPair, bitgoBackupPair); + 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(), + backupRootKeyShare: backupRoot.getKeyShare(), + backupRootVrfKeyShare: vrfBackup.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(bitgoUserPair.getKeyShare()), + 'User and BitGo child common keychains do not match at non-zero index' + ); + }); + + it('should reject root key material that is not a valid VRF envelope', async function () { + const [, backupRoot, bitgoRoot] = await DklsUtils.generateDKGKeyShares(); + const [, vrfBackup, vrfBitgo] = await DklsVrfUtils.generateVrfDKGKeyShares(); + 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 round1Nock = await nockDeriveRound1(bitgoUserPair, bitgoBackupPair); + const round2Nock = await nockDeriveRound2(bitgoUserPair, bitgoBackupPair); + const round3Nock = await nockDeriveRound3(bitgoUserPair, bitgoBackupPair); + 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: vrfBackup.getKeyShare(), + backupRootKeyShare: backupRoot.getKeyShare(), + backupRootVrfKeyShare: vrfBackup.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, backupRoot, bitgoRoot] = await DklsUtils.generateDKGKeyShares(); + // backup VRF keyshare party (1) fed as the USER's VRF share — must be rejected. + const [, vrfBackup, vrfBitgo] = await DklsVrfUtils.generateVrfDKGKeyShares(); + 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 round1Nock = await nockDeriveRound1(bitgoUserPair, bitgoBackupPair); + const round2Nock = await nockDeriveRound2(bitgoUserPair, bitgoBackupPair); + const round3Nock = await nockDeriveRound3(bitgoUserPair, bitgoBackupPair); + 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(), + backupRootKeyShare: backupRoot.getKeyShare(), + backupRootVrfKeyShare: 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; + } + + /** + * Server-side derive rounds: the BitGo party runs one hard-derive session per SDK + * party. R1 consumes the SDK's first messages and returns each BitGo pair's first + * message; the pairs' second messages (emitted during that round) are staged and + * returned on R2 alongside the finalization; R3 returns the child common keychain + * from the finalized BitGo sessions. + */ + let stagedBitgoUserMsg2: { message: string; signature: string } | undefined; + let stagedBitgoBackupMsg2: { message: string; signature: string } | undefined; + + async function nockDeriveRound1( + bitgoUserPair: DklsDrv.Derive, + bitgoBackupPair: 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.parentKeyId === undefined && + body.derivationIndex === undefined && + body.payload?.parentKeyId === BITGO_ROOT_KEY_ID && + body.payload?.derivationIndex === index + ) + .times(times) + .reply(200, async (uri, requestBody: { payload: MPCv2DeriveRound1Request }) => { + const { userGpgPublicKey, backupGpgPublicKey, userMsg1, backupMsg1 } = requestBody.payload; + userGpgPubKey = { partyId: 0, gpgKey: userGpgPublicKey }; + backupGpgPubKey = { partyId: 1, gpgKey: backupGpgPublicKey }; + + await DklsComms.decryptAndVerifyIncomingMessages( + { + p2pMessages: [], + broadcastMessages: [ + { from: 0, payload: { message: userMsg1.message, signature: userMsg1.signature } }, + { from: 1, payload: { message: backupMsg1.message, signature: backupMsg1.signature } }, + ], + }, + [userGpgPubKey, backupGpgPubKey], + [] + ); + + const bitgoUserMsg1Unsigned = await bitgoUserPair.initDerive(); + const bitgoBackupMsg1Unsigned = await bitgoBackupPair.initDerive(); + // Consume the SDK's first messages now: each BitGo pair session emits its + // own second message. Stage those for the R2 response. + const bitgoUserPairMsg2 = bitgoUserPair.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [{ payload: Buffer.from(userMsg1.message, 'base64'), from: 0 }], + }); + const bitgoBackupPairMsg2 = bitgoBackupPair.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [{ payload: Buffer.from(backupMsg1.message, 'base64'), from: 1 }], + }); + const signedStagedMessages = await DklsComms.encryptAndAuthOutgoingMessages( + { + broadcastMessages: [ + DklsTypes.serializeBroadcastMessage(bitgoUserPairMsg2.broadcastMessages[0]), + DklsTypes.serializeBroadcastMessage(bitgoBackupPairMsg2.broadcastMessages[0]), + ], + p2pMessages: [], + }, + [], + [bitgoGpgPrvKey] + ); + stagedBitgoUserMsg2 = signedStagedMessages.broadcastMessages[0].payload; + stagedBitgoBackupMsg2 = signedStagedMessages.broadcastMessages[1].payload; + + const signedMessages = await DklsComms.encryptAndAuthOutgoingMessages( + { + broadcastMessages: [ + DklsTypes.serializeBroadcastMessage(bitgoUserMsg1Unsigned), + DklsTypes.serializeBroadcastMessage(bitgoBackupMsg1Unsigned), + ], + p2pMessages: [], + }, + [], + [bitgoGpgPrvKey] + ); + const bitgoUserMsg1 = signedMessages.broadcastMessages[0]; + const bitgoBackupMsg1 = signedMessages.broadcastMessages[1]; + assert.ok(bitgoUserMsg1, 'bitgoUserMsg1 not found'); + assert.ok(bitgoBackupMsg1, 'bitgoBackupMsg1 not found'); + + return { + sessionId: 'testid' as NonEmptyString, + bitgoUserMsg1: { from: 2, ...bitgoUserMsg1.payload }, + bitgoBackupMsg1: { from: 2, ...bitgoBackupMsg1.payload }, + }; + }); + } + + async function nockDeriveRound2(bitgoUserPair: DklsDrv.Derive, bitgoBackupPair: DklsDrv.Derive, times = 1) { + return nock(bgUrl) + .post( + '/api/v2/mpc/generatekey', + (body) => + body.round === 'MPCv2Derive-R2' && + body.safeId === SAFE_ID && + body.parentKeyId === undefined && + body.derivationIndex === undefined && + body.payload?.parentKeyId === undefined && + body.payload?.derivationIndex === undefined + ) + .times(times) + .reply(200, async (uri, requestBody: { payload: MPCv2DeriveRound2Request }) => { + const { sessionId, userMsg2, backupMsg2 } = requestBody.payload; + await DklsComms.decryptAndVerifyIncomingMessages( + { + p2pMessages: [], + broadcastMessages: [ + { from: 0, payload: { message: userMsg2.message, signature: userMsg2.signature } }, + { from: 1, payload: { message: backupMsg2.message, signature: backupMsg2.signature } }, + ], + }, + [userGpgPubKey, backupGpgPubKey], + [] + ); + // Each pair session consumes its own msg2 (auto-fed) plus the SDK party's msg2. + bitgoUserPair.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [{ payload: Buffer.from(userMsg2.message, 'base64'), from: 0 }], + }); + bitgoBackupPair.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [{ payload: Buffer.from(backupMsg2.message, 'base64'), from: 1 }], + }); + assert.ok(stagedBitgoUserMsg2, 'staged BitGo user msg2 missing'); + assert.ok(stagedBitgoBackupMsg2, 'staged BitGo backup msg2 missing'); + return { + sessionId, + bitgoUserMsg2: { from: 2, ...stagedBitgoUserMsg2 }, + bitgoBackupMsg2: { from: 2, ...stagedBitgoBackupMsg2 }, + }; + }); + } + + async function nockDeriveRound3(bitgoUserPair: DklsDrv.Derive, bitgoBackupPair: DklsDrv.Derive, times = 1) { + return nock(bgUrl) + .post( + '/api/v2/mpc/generatekey', + (body) => + body.round === 'MPCv2Derive-R3' && + body.safeId === SAFE_ID && + body.parentKeyId === undefined && + body.derivationIndex === undefined && + body.payload?.parentKeyId === undefined && + body.payload?.derivationIndex === undefined + ) + .times(times) + .reply(200, async (uri, requestBody: { payload: MPCv2DeriveRound3Request }) => { + const { sessionId } = requestBody.payload; + const commonKeychain = DklsTypes.getCommonKeychain(bitgoUserPair.getKeyShare()); + assert.equal( + commonKeychain, + DklsTypes.getCommonKeychain(bitgoBackupPair.getKeyShare()), + 'BitGo pair sessions must agree on the child common keychain' + ); + return { sessionId, commonKeychain: commonKeychain 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 0c4ba50950..1669ef6731 100644 --- a/modules/sdk-core/package.json +++ b/modules/sdk-core/package.json @@ -40,7 +40,7 @@ ] }, "dependencies": { - "@bitgo/public-types": "6.66.0", + "@bitgo/public-types": "6.71.0", "@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..6f23db83fc 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,28 @@ 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: the SDK registers the user AND backup child keys (both carry + // encryptedPrv); the BitGo child key is minted by the server. + 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 +71,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 +131,16 @@ 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 safe's `ecdsaMpc` root blobs (each carries the DKLS signing + * keyshare and the Ristretto VRF keyshare), run the hard-derive ceremony against + * the server (SDK drives user and backup), register the user and backup children + * with the derived signing share encrypted under the Safe passphrase, then mint. */ async createWallet(params: CreateSafeWalletOptions): Promise { if (params.passphrase.length === 0) { @@ -115,12 +149,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 +161,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 +220,72 @@ export class Safe implements ISafe { return new Wallet(this.bitgo, coin, response); } + /** + * TSS wallet mint: decrypt both root blobs, seed and run the hard-derive ceremony + * with the server, register the derived user and backup children (encryptedPrv + * holds the signing share only), then POST the unchanged mint endpoint with both + * child ids. + */ + private async createTssWalletInSafe( + coin: IBaseCoin, + userRootId: string, + backupRootId: string, + bitgoRootId: string, + index: number, + params: CreateSafeWalletOptions + ): Promise { + const keychains = coin.keychains(); + const [userRootKeychain, backupRootKeychain] = await Promise.all([ + keychains.get({ id: userRootId }), + keychains.get({ id: backupRootId }), + ]); + if (userRootKeychain.source !== 'user') { + throw new InvalidRootKeychainSourceError(userRootKeychain.id, userRootKeychain.source); + } + if (backupRootKeychain.source !== 'backup') { + throw new InvalidRootKeychainSourceError(backupRootKeychain.id, backupRootKeychain.source); + } + const [userRootPrv, backupRootPrv] = await Promise.all([ + decryptKeychainPrivateKey(this.bitgo, userRootKeychain, params.passphrase), + decryptKeychainPrivateKey(this.bitgo, backupRootKeychain, params.passphrase), + ]); + if (!userRootPrv || !backupRootPrv) { + throw new IncorrectPasswordError(); + } + + const userRootMaterial = ECDSAUtils.parseVrfKeyEnvelopes(userRootPrv); + const backupRootMaterial = ECDSAUtils.parseVrfKeyEnvelopes(backupRootPrv); + + const tssUtils = new ECDSAUtils.EcdsaVrfMPCv2Utils(this.bitgo, coin); + const { userKeychain, backupKeychain } = await tssUtils.createSafeChildKeychains({ + passphrase: params.passphrase, + enterprise: this.enterpriseId(), + safeId: this.id(), + // Derive from the safe's BitGo root key (its material holds the VRF share). + parentKeyId: bitgoRootId, + derivationIndex: index, + userRootKeyId: userRootId, + backupRootKeyId: backupRootId, + userRootKeyShare: userRootMaterial.signing, + userRootVrfKeyShare: userRootMaterial.vrf, + backupRootKeyShare: backupRootMaterial.signing, + backupRootVrfKeyShare: backupRootMaterial.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 +293,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..88e9f55c92 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; @@ -446,6 +449,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 +1163,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 +1215,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..8e1f2c9eb7 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 safe-child hard-derivation ceremony. The derive rounds use + * the same endpoint as MPCv2 keygen (`/mpc/generatekey`), dispatched by the + * `MPCv2Derive-R*` round values. `parentKeyId` and `derivationIndex` live on the + * R1 payload (public-types' `MPCv2DeriveRound1Request`), not on the generatekey body. + */ +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..9df7bbea88 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,15 @@ -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 { + MPCv2DeriveRound1Response, + MPCv2DeriveRound2Response, + MPCv2DeriveRound3Response, + MPCv2KeyGenRound1Response, + MPCv2KeyGenRound2Response, + MPCv2KeyGenStateEnum, +} from '@bitgo/public-types'; import { KeychainsTriplet } from '../../../baseCoin'; import { DecryptedRetrofitPayload } from '../../../keychain/iKeychains'; import { EncryptionVersion } from '../../../../api'; @@ -11,7 +17,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'; /** @@ -83,6 +93,47 @@ 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) { + throw new Error(`Failed to decode safe MPC root key envelope: ${(e as Error).message}`); + } + if (typeof envelope !== 'object' || envelope === null) { + throw new Error('Invalid safe MPC root key envelope'); + } + const { version, prvKeyShare, vrf } = envelope as { version?: unknown; prvKeyShare?: unknown; vrf?: unknown }; + 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. @@ -528,6 +579,354 @@ export class EcdsaVrfMPCv2Utils extends EcdsaMPCv2Utils { } } + /** + * Sends the safe-child hard-derivation round 1: the SDK parties' first broadcast + * messages, authenticated and encrypted to the BitGo party. + */ + async sendDerivationRound1BySender( + senderFn: EcdsaMPCv2DeriveKeySendFn, + userGpgPublicKey: string, + backupGpgPublicKey: string, + payload: DklsTypes.AuthEncMessages, + parentKeyId: string, + derivationIndex: number + ): Promise { + assert(NonEmptyString.is(userGpgPublicKey), 'User GPG public key is required'); + assert(NonEmptyString.is(backupGpgPublicKey), 'Backup 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'); + const backupMsg1 = payload.broadcastMessages.find((m) => m.from === MPCv2PartiesEnum.BACKUP)?.payload; + assert(backupMsg1, 'Backup message 1 not found in broadcast messages'); + + return senderFn(MPCv2KeyGenStateEnum['MPCv2Derive-R1'], { + userGpgPublicKey, + backupGpgPublicKey, + userMsg1: { from: MPCv2PartiesEnum.USER, ...userMsg1 }, + backupMsg1: { from: MPCv2PartiesEnum.BACKUP, ...backupMsg1 }, + parentKeyId, + derivationIndex, + }); + } + + /** + * Sends the safe-child hard-derivation round 2: the SDK parties' second broadcast + * messages. The response carries the BitGo pair's second messages, which finalize + * the SDK sessions. + */ + 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 backupMsg2 = payload.broadcastMessages.find((m) => m.from === MPCv2PartiesEnum.BACKUP)?.payload; + assert(backupMsg2, 'Backup message 2 not found in broadcast messages'); + + return senderFn(MPCv2KeyGenStateEnum['MPCv2Derive-R2'], { + sessionId, + userMsg2: { from: MPCv2PartiesEnum.USER, ...userMsg2 }, + backupMsg2: { from: MPCv2PartiesEnum.BACKUP, ...backupMsg2 }, + }); + } + + /** + * Sends the safe-child hard-derivation round 3: an acknowledgment that returns + * the child common keychain the server derived from its own pair sessions. + */ + 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-derivation ceremony: the SDK drives the user (0) and + * backup (1) parties of the DKLS hard-derive protocol against the BitGo party, + * exactly as it drives two of the three parties in keygen. + * + * Three round trips wrap the two-broadcast-round derive protocol per party pair + * (user↔bitgo, backup↔bitgo): + * - R1 sends the parties' first messages; the response carries the BitGo pair's + * first messages, which the SDK uses to emit its second messages. + * - R2 sends the parties' second messages; the BitGo pair finalizes its sessions + * and the response carries its second messages, which finalize the SDK sessions. + * - R3 returns the child common keychain, which the server derives from its own + * pair sessions. + * + * The sessions are seeded from the decrypted root blobs created during root + * keygen: each blob's envelope holds the DKLS signing keyshare and the Ristretto + * VRF keyshare, which together derive the child at the hardened path `m/'`. + * + * Registered children carry `encryptedPrv` (and `reducedEncryptedPrv`) holding + * the derived signing share only, encrypted under the Safe passphrase like an + * ordinary TSS keychain. The BitGo child key is minted by the server, so only + * the user and backup child keychains are returned. + */ + async createSafeChildKeychains(params: { + passphrase: string; + enterprise: string; + safeId: string; + // The safe's `ecdsaMpc` BitGo root key id; the server derives children from + // this root. + parentKeyId: string; + // Sequential hardened derivation index; the child path is `m/'`. + derivationIndex: number; + // Root key ids each registered child keychain points back to. + userRootKeyId: string; + backupRootKeyId: string; + // Decrypted root blob contents: signing share + VRF share per party. + userRootKeyShare: Buffer; + userRootVrfKeyShare: Buffer; + backupRootKeyShare: Buffer; + backupRootVrfKeyShare: Buffer; + originalPasscodeEncryptionCode?: string; + webauthnInfo?: WebauthnKeyEncryptionInfo; + encryptionVersion?: EncryptionVersion; + }): Promise> { + const userGpgKey = await generateGPGKeyPair('secp256k1'); + const backupGpgKey = await generateGPGKeyPair('secp256k1'); + + // Get the BitGo public key based on user/enterprise feature flags + // If it doesn't work, use the default public key from the constants + 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())) { + // Ensure the public key is one of the expected BitGo public keys when in test or prod. + assert(isBitgoMpcPubKey(bitgoPublicGpgKey, 'mpcv2'), 'Invalid BitGo GPG public key'); + } + + const userGpgPrvKey: DklsTypes.PartyGpgKey = { + partyId: MPCv2PartiesEnum.USER, + gpgKey: userGpgKey.privateKey, + }; + const backupGpgPrvKey: DklsTypes.PartyGpgKey = { + partyId: MPCv2PartiesEnum.BACKUP, + gpgKey: backupGpgKey.privateKey, + }; + const bitgoGpgPubKey: DklsTypes.PartyGpgKey = { + partyId: MPCv2PartiesEnum.BITGO, + gpgKey: bitgoPublicGpgKey, + }; + + // Hardened derivation path `m/'`; children use single-node hardened paths. + const path = hardenedDerivationPath(params.derivationIndex); + + const userDeriveSession = new DklsDrv.Derive( + 3, + 2, + MPCv2PartiesEnum.USER, + params.userRootKeyShare, + params.userRootVrfKeyShare, + path + ); + const backupDeriveSession = new DklsDrv.Derive( + 3, + 2, + MPCv2PartiesEnum.BACKUP, + params.backupRootKeyShare, + params.backupRootVrfKeyShare, + path + ); + + // #region round 1 + const userRound1Msg = await userDeriveSession.initDerive(); + const backupRound1Msg = await backupDeriveSession.initDerive(); + const round1SerializedMessages = DklsTypes.serializeMessages({ + broadcastMessages: [userRound1Msg, backupRound1Msg], + p2pMessages: [], + }); + const round1Messages = await DklsComms.encryptAndAuthOutgoingMessages( + round1SerializedMessages, + [bitgoGpgPubKey], + [userGpgPrvKey, backupGpgPrvKey] + ); + const userMsg1 = round1Messages.broadcastMessages.find((m) => m.from === MPCv2PartiesEnum.USER)?.payload; + const backupMsg1 = round1Messages.broadcastMessages.find((m) => m.from === MPCv2PartiesEnum.BACKUP)?.payload; + assert(userMsg1, 'User message 1 not found in broadcast messages'); + assert(backupMsg1, 'Backup message 1 not found in broadcast messages'); + assert(NonEmptyString.is(userGpgKey.publicKey), 'User GPG public key is required'); + assert(NonEmptyString.is(backupGpgKey.publicKey), 'Backup GPG public key is required'); + + const { sessionId, bitgoUserMsg1, bitgoBackupMsg1 } = await this.sendDerivationRound1BySender( + KeyGenSenderForSafeChild(this.bitgo, params.enterprise, params.safeId), + userGpgKey.publicKey, + backupGpgKey.publicKey, + { + ...round1Messages, + }, + params.parentKeyId, + params.derivationIndex + ); + assert(bitgoUserMsg1, 'BitGo derive message 1 to user not found in round 1 response'); + assert(bitgoBackupMsg1, 'BitGo derive message 1 to backup not found in round 1 response'); + // #endregion + + // #region round 2 + const decryptedBitgoToUserRound1 = await DklsComms.decryptAndVerifyIncomingMessages( + { p2pMessages: [], broadcastMessages: [this.formatBitgoBroadcastMessage(bitgoUserMsg1)] }, + [bitgoGpgPubKey], + [] + ); + const bitgoToUserRound1Msg = decryptedBitgoToUserRound1.broadcastMessages.find( + (m) => m.from === MPCv2PartiesEnum.BITGO + ); + assert(bitgoToUserRound1Msg, 'BitGo to User derive message 1 not found in broadcast messages'); + const bitgoToUserDeriveMsg1 = DklsTypes.deserializeBroadcastMessage(bitgoToUserRound1Msg); + + const decryptedBitgoToBackupRound1 = await DklsComms.decryptAndVerifyIncomingMessages( + { p2pMessages: [], broadcastMessages: [this.formatBitgoBroadcastMessage(bitgoBackupMsg1)] }, + [bitgoGpgPubKey], + [] + ); + const bitgoToBackupRound1Msg = decryptedBitgoToBackupRound1.broadcastMessages.find( + (m) => m.from === MPCv2PartiesEnum.BITGO + ); + assert(bitgoToBackupRound1Msg, 'BitGo to Backup derive message 1 not found in broadcast messages'); + const bitgoToBackupDeriveMsg1 = DklsTypes.deserializeBroadcastMessage(bitgoToBackupRound1Msg); + + // Each SDK session consumes the BitGo party's first message and emits its own + // second message; the party's own first message is re-fed automatically by the + // wasm session, which validates the sender set as {self, bitgo}. + const userRound2Messages = userDeriveSession.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [bitgoToUserDeriveMsg1], + }); + const backupRound2Messages = backupDeriveSession.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [bitgoToBackupDeriveMsg1], + }); + const userMsg2 = userRound2Messages.broadcastMessages.find((m) => m.from === MPCv2PartiesEnum.USER); + const backupMsg2 = backupRound2Messages.broadcastMessages.find((m) => m.from === MPCv2PartiesEnum.BACKUP); + assert(userMsg2, 'User message 2 not found in broadcast messages'); + assert(backupMsg2, 'Backup message 2 not found in broadcast messages'); + + const round2SerializedMessages = DklsTypes.serializeMessages({ + broadcastMessages: [userMsg2, backupMsg2], + p2pMessages: [], + }); + const round2Messages = await DklsComms.encryptAndAuthOutgoingMessages( + round2SerializedMessages, + [bitgoGpgPubKey], + [userGpgPrvKey, backupGpgPrvKey] + ); + const userMsg2Signed = round2Messages.broadcastMessages.find((m) => m.from === MPCv2PartiesEnum.USER)?.payload; + const backupMsg2Signed = round2Messages.broadcastMessages.find((m) => m.from === MPCv2PartiesEnum.BACKUP)?.payload; + assert(userMsg2Signed, 'User message 2 not found in signed broadcast messages'); + assert(backupMsg2Signed, 'Backup message 2 not found in signed broadcast messages'); + + const { + sessionId: sessionIdRound2, + bitgoUserMsg2, + bitgoBackupMsg2, + } = await this.sendDerivationRound2BySender( + KeyGenSenderForSafeChild(this.bitgo, params.enterprise, params.safeId), + sessionId, + round2Messages + ); + assert(bitgoUserMsg2, 'BitGo derive message 2 to user not found in round 2 response'); + assert(bitgoBackupMsg2, 'BitGo derive message 2 to backup not found in round 2 response'); + // Verify the round-2 response belongs to this ceremony before applying it. + assert.equal(sessionId, sessionIdRound2, 'Round 1 and 2 Session IDs do not match'); + + // The BitGo pair's second messages finalize the SDK sessions locally. + const decryptedBitgoToUserRound2 = await DklsComms.decryptAndVerifyIncomingMessages( + { p2pMessages: [], broadcastMessages: [this.formatBitgoBroadcastMessage(bitgoUserMsg2)] }, + [bitgoGpgPubKey], + [] + ); + const bitgoToUserRound2Msg = decryptedBitgoToUserRound2.broadcastMessages.find( + (m) => m.from === MPCv2PartiesEnum.BITGO + ); + assert(bitgoToUserRound2Msg, 'BitGo to User derive message 2 not found in broadcast messages'); + userDeriveSession.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [DklsTypes.deserializeBroadcastMessage(bitgoToUserRound2Msg)], + }); + + const decryptedBitgoToBackupRound2 = await DklsComms.decryptAndVerifyIncomingMessages( + { p2pMessages: [], broadcastMessages: [this.formatBitgoBroadcastMessage(bitgoBackupMsg2)] }, + [bitgoGpgPubKey], + [] + ); + const bitgoToBackupRound2Msg = decryptedBitgoToBackupRound2.broadcastMessages.find( + (m) => m.from === MPCv2PartiesEnum.BITGO + ); + assert(bitgoToBackupRound2Msg, 'BitGo to Backup derive message 2 not found in broadcast messages'); + backupDeriveSession.handleIncomingMessages({ + p2pMessages: [], + broadcastMessages: [DklsTypes.deserializeBroadcastMessage(bitgoToBackupRound2Msg)], + }); + // #endregion + + // #region round 3 + 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'); + // #endregion + + // #region keychain creation + const userPrivateMaterial = userDeriveSession.getKeyShare(); + const backupPrivateMaterial = backupDeriveSession.getKeyShare(); + const userReducedPrivateMaterial = userDeriveSession.getReducedKeyShare(); + const backupReducedPrivateMaterial = backupDeriveSession.getReducedKeyShare(); + + const userCommonKeychain = DklsTypes.getCommonKeychain(userPrivateMaterial); + const backupCommonKeychain = DklsTypes.getCommonKeychain(backupPrivateMaterial); + + assert.equal(commonKeychain, userCommonKeychain, 'User and Bitgo Common keychains do not match'); + assert.equal(commonKeychain, backupCommonKeychain, 'Backup 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, + backupPrivateMaterial, + backupReducedPrivateMaterial, + 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]); + // #endregion + + return { userKeychain, backupKeychain }; + } finally { + encryptionSession?.destroy(); + } + } + private getUserAndBackupSessions(retrofit?: DecryptedRetrofitPayload) { if (retrofit) { const retrofitData = this.getMpcV2RetrofitDataFromMpcV1Keys({ 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..2709a740d4 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,29 @@ export type GenerateMPCv2KeyRequestBody = t.TypeOf & MpcV2VrfKeyGenResponseFields; + +/** + * Round states for the safe-child hard-derivation ceremony (DKLS hard derive, VRF + * backed); values come from `@bitgo/public-types` `MPCv2KeyGenStateEnum['MPCv2Derive-R*']`. + * Three round trips wrap the two-broadcast-round derive protocol: the server + * finalizes its pair sessions between the SDK's rounds and returns the child common + * keychain on the third. + */ + +/** + * The safe-child hard-derivation ceremony rides the same signed broadcast message + * shapes as MPCv2 keygen: the SDK drives user (0) and backup (1); the server runs + * one hard-derive session per SDK party and returns a per-pair broadcast message on + * every round. The request/response codecs are published by `@bitgo/public-types`; + * the R1 request additionally carries `parentKeyId` (BitGo root key id) and + * `derivationIndex` (sequential child index). + */ +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..d5b8a67f9b 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,63 @@ 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 derive ceremony: registers user+backup children and posts the tss body', 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 envelopes: `{version: 1, prvKeyShare, vrf}`. + const userBlob = ECDSAUtils.buildVrfKeyEnvelopes( + Buffer.from('signing-1'), + Buffer.from('reduced-1'), + Buffer.from('vrf-1') + ).envelope.toString('base64'); + const backupBlob = ECDSAUtils.buildVrfKeyEnvelopes( + Buffer.from('signing-2'), + Buffer.from('reduced-2'), + Buffer.from('vrf-2') + ).envelope.toString('base64'); + keychainsGet + .onFirstCall() + .resolves({ id: 'ecdsa-user', source: 'user', encryptedPrv: `enc:${userBlob}` }) + .onSecondCall() + .resolves({ id: 'ecdsa-backup', source: 'backup', encryptedPrv: `enc:${backupBlob}` }); + 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 }), + }); + + // Child keychains registered by the ceremony (mocked through the tss utils). + const ceremonyStub = sinon.stub(ECDSAUtils.EcdsaVrfMPCv2Utils.prototype, 'createSafeChildKeychains').resolves({ + userKeychain: { id: 'ecdsa-child-user' }, + backupKeychain: { id: 'ecdsa-child-backup' }, + } as never); + + const wallet = await safe.createWallet({ coin: 'hteth', label: 'evm', passphrase: 'pw', multisigType: 'tss' }); + + derivationQuery.calledOnceWithExactly({ slot: 'ecdsaMpc' }).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.userRootKeyShare.should.deepEqual(Buffer.from('signing-1')); + ceremonyArgs.userRootVrfKeyShare.should.deepEqual(Buffer.from('vrf-1')); + ceremonyArgs.backupRootKeyShare.should.deepEqual(Buffer.from('signing-2')); + + // The mint body carries the two registered child ids and the tss multisigType. + mintSend.firstCall.args[0].should.eql({ + coin: 'hteth', + label: 'evm', + type: 'hot', + multisigType: 'tss', + keys: ['ecdsa-child-user', 'ecdsa-child-backup'], + }); + 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..7ef6257987 --- /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; + +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 !== 32) { + throw Error(`Seed should be 32 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 hard-derive session from previous session data. + * Note: This should not be used for Round 1 as that's the initialization phase. + * The round state is re-derived from the wasm session bytes, not trusted from the + * caller-supplied enum, so a tampered `deriveState` cannot steer the protocol. + */ + 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..6250c7ab10 --- /dev/null +++ b/modules/sdk-lib-mpc/test/unit/tss/dkls-vrf/derive.ts @@ -0,0 +1,196 @@ +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 userMsg2 = user.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 = user.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 41c57b723d..70947804b9 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.71.0": + version "6.71.0" + resolved "https://registry.npmjs.org/@bitgo/public-types/-/public-types-6.71.0.tgz#b7bc469e0e861f49368d8e486ede023a094de73d" + integrity sha512-CkmW7aIdivwMrukPPrr+JZhe5Mt2N+04Xwe2XaeJdiCP5Rtqn0ATJrH3Jbzmn/cm1OwU31b/sXfI/HkK5RMk4Q== + 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"