Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion modules/bitgo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion modules/sdk-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
]
},
"dependencies": {
"@bitgo/public-types": "6.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",
Expand Down
7 changes: 5 additions & 2 deletions modules/sdk-core/src/bitgo/safe/iSafe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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';
}

Expand Down
161 changes: 134 additions & 27 deletions modules/sdk-core/src/bitgo/safe/safe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<RootKeyType, 'secp256k1Multisig'> {
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) {
Expand All @@ -59,13 +71,27 @@ function onchainSlotForCoin(coin: IBaseCoin): Extract<RootKeyType, 'secp256k1Mul
throw new Error(`Coin '${coin.getChain()}' is not supported for safe wallet minting`);
}

function userRootIdFromSafe(safe: SafeData, slot: RootKeyType): string | undefined {
function tssSlotForCoin(coin: IBaseCoin): Extract<RootKeyType, 'ecdsaMpc'> {
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;
}

/**
Expand Down Expand Up @@ -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/<index>'`), 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<Wallet> {
if (params.passphrase.length === 0) {
Expand All @@ -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');
Expand All @@ -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') {
Expand Down Expand Up @@ -175,41 +220,103 @@ 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<Wallet> {
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<SafeData> {
const response = await this.bitgo.get(this.url()).result();
return decodeWithCodec(SafeData, response, 'SafeData');
}

/**
* 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<SafeData> {
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<WalletShareData> {
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<SafeShareData[]> {
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<SafeShareData> {
throw new Error('Safe.acceptShare is not yet implemented (WCN-1204)');
throw new Error('Safe.acceptShare is not yet implemented');
}

/**
Expand Down
13 changes: 9 additions & 4 deletions modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<KeychainsTriplet> {
const { userSession, backupSession } = this.getUserAndBackupSession(2, 3, params.retrofit);
Expand Down Expand Up @@ -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/<index>'`).
child?: { parentKeyId?: string; index?: number }
): Promise<Keychain> {
let source: string;
let encryptedPrv: string | undefined = undefined;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<T extends GenerateMPCv2KeyRequestResponse> = (
Expand All @@ -10,8 +15,8 @@ export type EcdsaMPCv2KeyGenSendFn<T extends GenerateMPCv2KeyRequestResponse> =
export function KeyGenSenderForEnterprise<T extends GenerateMPCv2KeyRequestResponse>(
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<T> {
return (round, payload) => {
Expand All @@ -21,3 +26,33 @@ export function KeyGenSenderForEnterprise<T extends GenerateMPCv2KeyRequestRespo
.result();
};
}

export type EcdsaMPCv2DeriveKeySendFn<T extends GenerateMPCv2DeriveKeyRequestResponse> = (
round: MPCv2KeyGenState,
payload: GenerateMPCv2DeriveKeyRequest
) => Promise<T>;

/**
* 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<T extends GenerateMPCv2DeriveKeyRequestResponse>(
bitgo: BitGoBase,
enterprise: string,
safeId: string
): EcdsaMPCv2DeriveKeySendFn<T> {
return (round, payload) => {
return bitgo
.post(bitgo.url('/mpc/generatekey', 2))
.send({
enterprise,
safeId,
type: KeyGenTypeEnum.MPCv2,
round,
payload,
})
.result();
};
}
Loading
Loading