diff --git a/modules/sdk-core/src/bitgo/wallet/iWallets.ts b/modules/sdk-core/src/bitgo/wallet/iWallets.ts index ac82fc56bd..2055222fcd 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallets.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallets.ts @@ -63,6 +63,19 @@ export interface GenerateSMCMpcWalletOptions extends GenerateBaseMpcWalletOption coldDerivationSeed?: string; } +export interface CreateKeychainCallbackParams { + source: 'user' | 'backup'; + coin: string; +} + +export interface CreateKeychainCallbackResult { + pub: string; + type: 'independent'; + source: string; +} + +export type CreateKeychainCallback = (params: CreateKeychainCallbackParams) => Promise; + export interface GenerateWalletOptions { label?: string; passphrase?: string; @@ -95,6 +108,14 @@ export interface GenerateWalletOptions { /** Optional WebAuthn PRF-based encryption info. When provided, the user private key is additionally encrypted with the PRF-derived passphrase so the server can store a WebAuthn-protected copy. */ webauthnInfo?: WebauthnKeyEncryptionInfo; encryptionVersion?: EncryptionVersion; + /** Delegates user/backup key creation to an external signer (onchain multisig only). */ + createKeychainCallback?: CreateKeychainCallback; +} + +export interface GenerateWalletWithExternalSignerOptions + extends Omit { + label: string; + createKeychainCallback: CreateKeychainCallback; } export const GenerateLightningWalletOptionsCodec = t.intersection( @@ -281,6 +302,7 @@ export interface IWallets { generateWallet( params?: GenerateWalletOptions ): Promise; + generateWalletWithExternalSigner(params: GenerateWalletWithExternalSignerOptions): Promise; listShares(params?: Record): Promise; getShare(params?: { walletShareId?: string }): Promise; updateShare(params?: UpdateShareOptions): Promise; diff --git a/modules/sdk-core/src/bitgo/wallet/wallets.ts b/modules/sdk-core/src/bitgo/wallet/wallets.ts index 2a6916707b..9c7be84fc5 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallets.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallets.ts @@ -12,7 +12,7 @@ import * as common from '../../common'; import { IBaseCoin, KeychainsTriplet, SupplementGenerateWalletOptions } from '../baseCoin'; import { BitGoBase } from '../bitgoBase'; import { getSharedSecret } from '../ecdh'; -import { AddKeychainOptions, Keychain, KeyIndices } from '../keychain'; +import { AddKeychainOptions, Keychain, KeyIndices, KeyType } from '../keychain'; import { decodeOrElse, promiseProps, RequestTracer } from '../utils'; import { AcceptShareOptions, @@ -31,6 +31,7 @@ import { GenerateMpcWalletOptions, GenerateSMCMpcWalletOptions, GenerateWalletOptions, + GenerateWalletWithExternalSignerOptions, GetWalletByAddressOptions, GetWalletOptions, GoAccountWalletWithUserKeychain, @@ -360,6 +361,10 @@ export class Wallets implements IWallets { throw new Error('missing required string parameter label'); } + if (params.createKeychainCallback) { + return this.generateWalletWithExternalSigner(params as GenerateWalletWithExternalSignerOptions); + } + const { type = 'hot', label, passphrase, enterprise, isDistributedCustody, evmKeyRingReferenceWalletId } = params; const isTss = params.multisigType === 'tss' && this.baseCoin.supportsTss(); const canEncrypt = !!passphrase && typeof passphrase === 'string'; @@ -718,6 +723,189 @@ export class Wallets implements IWallets { } } + /** + * Generate an onchain multisig wallet using an external signer for user and backup key creation. + * 1. Calls createKeychainCallback for user and backup keys + * 2. Uploads keychains via keychains().add() + * 3. Creates the BitGo key on the service + * 4. Creates the wallet on BitGo with the 3 public keys + * @param params + */ + async generateWalletWithExternalSigner( + params: GenerateWalletWithExternalSignerOptions + ): Promise { + if (!_.isFunction(params.createKeychainCallback)) { + throw new Error('missing required function parameter createKeychainCallback'); + } + + const multisigType = params.multisigType ?? this.baseCoin.getDefaultMultisigType(); + if (multisigType !== 'onchain') { + throw new Error('external signer wallet generation is only supported for onchain multisig wallets'); + } + + const conflictingParams = ['passphrase', 'userKey', 'backupXpub', 'backupXpubProvider'] as const; + for (const key of conflictingParams) { + if (!_.isUndefined(params[key])) { + throw new Error(`createKeychainCallback cannot be used with ${key}`); + } + } + + const { label, createKeychainCallback, type = 'hot', enterprise, isDistributedCustody } = params; + + if (type === 'custodial') { + throw new Error('external signer wallet generation is not supported for custodial onchain wallets'); + } + + if (!_.isUndefined(params.webauthnInfo)) { + throw new Error('webauthnInfo is not supported for external signer wallet generation'); + } + + if (!_.isUndefined(params.passcodeEncryptionCode)) { + throw new Error('passcodeEncryptionCode is not supported for external signer wallet generation'); + } + + if (isDistributedCustody) { + if (!enterprise) { + throw new Error('must provide enterprise when creating distributed custody wallet'); + } + if (type !== 'cold') { + throw new Error('distributed custody wallets must be type: cold'); + } + } + + if (params.gasPrice && params.eip1559) { + throw new Error('can not use both eip1559 and gasPrice values'); + } + + const walletParams: SupplementGenerateWalletOptions = { + label, + m: 2, + n: 3, + keys: [], + type, + }; + + if (!_.isUndefined(enterprise)) { + if (!_.isString(enterprise)) { + throw new Error('invalid enterprise argument, expecting string'); + } + walletParams.enterprise = enterprise; + } + + if (!_.isUndefined(params.disableTransactionNotifications)) { + if (!_.isBoolean(params.disableTransactionNotifications)) { + throw new Error('invalid disableTransactionNotifications argument, expecting boolean'); + } + walletParams.disableTransactionNotifications = params.disableTransactionNotifications; + } + + if (!_.isUndefined(params.gasPrice)) { + const gasPriceBN = new BigNumber(params.gasPrice); + if (gasPriceBN.isNaN()) { + throw new Error('invalid gas price argument, expecting number or number as string'); + } + walletParams.gasPrice = gasPriceBN.toString(); + } + + if (!_.isUndefined(params.eip1559) && !_.isEmpty(params.eip1559)) { + const maxFeePerGasBN = new BigNumber(params.eip1559.maxFeePerGas); + if (maxFeePerGasBN.isNaN()) { + throw new Error('invalid max fee argument, expecting number or number as string'); + } + const maxPriorityFeePerGasBN = new BigNumber(params.eip1559.maxPriorityFeePerGas); + if (maxPriorityFeePerGasBN.isNaN()) { + throw new Error('invalid priority fee argument, expecting number or number as string'); + } + walletParams.eip1559 = { + maxFeePerGas: maxFeePerGasBN.toString(), + maxPriorityFeePerGas: maxPriorityFeePerGasBN.toString(), + }; + } + + if (!_.isUndefined(params.walletVersion)) { + if (!_.isNumber(params.walletVersion)) { + throw new Error('invalid walletVersion provided, expecting number'); + } + walletParams.walletVersion = params.walletVersion; + } + + const reqId = new RequestTracer(); + const coin = this.baseCoin.getChain(); + + const createAndUploadKeychain = async (source: 'user' | 'backup'): Promise => { + const keychainFromCallback = await createKeychainCallback({ source, coin }); + if (keychainFromCallback.source !== source) { + throw new Error(`createKeychainCallback returned source ${keychainFromCallback.source}, expected ${source}`); + } + // Validate that the callback returned a key for the correct coin + // Note: This assumes the callback includes coin-specific validation logic + // For additional safety, consider adding coin-specific public key format validation here + + // Validate keyType - for onchain multisig, should always be 'independent' + if (keychainFromCallback.type !== 'independent') { + throw new Error(`createKeychainCallback returned invalid type ${keychainFromCallback.type}, expected 'independent' for onchain multisig`); + } + + return this.baseCoin.keychains().add({ + pub: keychainFromCallback.pub, + keyType: keychainFromCallback.type, + source: keychainFromCallback.source, + reqId, + }); + }; + + // Create BitGo keychain first, then create user/backup serially to handle errors properly + const bitgoKeychain = await this.baseCoin + .keychains() + .createBitGo({ enterprise, reqId, isDistributedCustody: params.isDistributedCustody }); + + let userKeychain: Keychain; + let backupKeychain: Keychain; + + try { + userKeychain = await createAndUploadKeychain('user'); + } catch (error) { + throw new Error(`Failed to create user keychain: ${error.message}`); + } + + try { + backupKeychain = await createAndUploadKeychain('backup'); + } catch (error) { + throw new Error(`Failed to create backup keychain: ${error.message}`); + } + + walletParams.keys = [userKeychain.id, backupKeychain.id, bitgoKeychain.id]; + + // Note: keySignatures are not generated for external signers since the private keys + // are not accessible. This means the external signer must provide proper attestation + // through other means (e.g., AKM's attestation mechanism). Standard generateWallet + // computes keySignatures.backup/bitgo by signing with the user private key, but + // external signers cannot do this. + + const keychains = { + userKeychain, + backupKeychain, + bitgoKeychain, + }; + + if (_.includes(['xrp', 'xlm', 'cspr'], this.baseCoin.getFamily()) && !_.isUndefined(params.rootPrivateKey)) { + walletParams.rootPrivateKey = params.rootPrivateKey; + } + + const finalWalletParams = await this.baseCoin.supplementGenerateWallet(walletParams, keychains); + + this.bitgo.setRequestTracer(reqId); + const newWallet = await this.bitgo.post(this.baseCoin.url('/wallet/add')).send(finalWalletParams).result(); + + return { + wallet: new Wallet(this.bitgo, this.baseCoin, newWallet), + userKeychain, + backupKeychain, + bitgoKeychain, + responseType: 'WalletWithKeychains', + }; + } + /** * List the user's wallet shares * @param params diff --git a/modules/sdk-core/test/unit/bitgo/wallet/walletsExternalSigner.ts b/modules/sdk-core/test/unit/bitgo/wallet/walletsExternalSigner.ts new file mode 100644 index 0000000000..929fbe37e3 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/wallet/walletsExternalSigner.ts @@ -0,0 +1,236 @@ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import 'should'; + +import { Wallets } from '../../../../src/bitgo/wallet/wallets'; +import { CreateKeychainCallback } from '../../../../src/bitgo/wallet/iWallets'; + +describe('Wallets - external signer onchain wallet generation', function () { + let wallets: Wallets; + let mockBitGo: any; + let mockBaseCoin: any; + let mockKeychains: any; + let createKeychainCallback: sinon.SinonStub, ReturnType>; + let sendStub: sinon.SinonStub; + + const userPub = + 'xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8'; + const backupPub = + 'xpub661MyMwAqRbcGczjuMoRm6dXaLDEhW1u34gKenbeYqAix21mdUKJyuyu5F1rzYGVxyL6tmgBUAEPrEz92mBXjByMRiJdba9wpnN37RLLAXa'; + const bitgoPub = + 'xpub661MyMwAqRbcEYS8w7XLSVeEsBXy79zSzH1J8vCdxAZningWLdN3zgtU6LBpB85b3D2yc8sfvZU521AAwdZafEz7mnzBBsz4wKY5fTtTQBm'; + + beforeEach(function () { + createKeychainCallback = sinon.stub(); + createKeychainCallback.withArgs({ source: 'user', coin: 'tbtc' }).resolves({ + pub: userPub, + type: 'independent', + source: 'user', + }); + createKeychainCallback.withArgs({ source: 'backup', coin: 'tbtc' }).resolves({ + pub: backupPub, + type: 'independent', + source: 'backup', + }); + + mockKeychains = { + add: sinon.stub().callsFake(async (params: { pub: string; source: string }) => ({ + id: `${params.source}-key-id`, + pub: params.pub, + source: params.source, + })), + createBitGo: sinon.stub().resolves({ id: 'bitgo-key-id', pub: bitgoPub }), + }; + + const mockWalletData = { id: 'wallet-id', keys: ['user-key-id', 'backup-key-id', 'bitgo-key-id'] }; + + sendStub = sinon.stub().returns({ + result: sinon.stub().resolves(mockWalletData), + }); + mockBitGo = { + post: sinon.stub().returns({ send: sendStub }), + setRequestTracer: sinon.stub(), + }; + + mockBaseCoin = { + isEVM: sinon.stub().returns(false), + supportsTss: sinon.stub().returns(true), + getFamily: sinon.stub().returns('btc'), + getChain: sinon.stub().returns('tbtc'), + getDefaultMultisigType: sinon.stub().returns('onchain'), + keychains: sinon.stub().returns(mockKeychains), + url: sinon.stub().returns('/api/v2/tbtc/wallet/add'), + getConfig: sinon.stub().returns({ features: [] }), + supplementGenerateWallet: sinon.stub().callsFake((walletParams: unknown) => Promise.resolve(walletParams)), + }; + + wallets = new Wallets(mockBitGo, mockBaseCoin); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe('generateWalletWithExternalSigner', function () { + it('should create user and backup keys via callback and create wallet', async function () { + const result = await wallets.generateWalletWithExternalSigner({ + label: 'External Signer Wallet', + enterprise: 'enterprise-id', + createKeychainCallback, + }); + + assert.strictEqual(createKeychainCallback.callCount, 2); + assert.strictEqual(mockKeychains.add.callCount, 2); + assert.strictEqual(mockKeychains.createBitGo.calledOnce, true); + assert.strictEqual(mockBitGo.post.calledOnce, true); + + const addUserParams = mockKeychains.add.getCall(0).args[0]; + addUserParams.should.have.property('pub', userPub); + addUserParams.should.have.property('keyType', 'independent'); + addUserParams.should.have.property('source', 'user'); + + const walletBody = sendStub.firstCall.args[0]; + walletBody.keys.should.deepEqual(['user-key-id', 'backup-key-id', 'bitgo-key-id']); + walletBody.label.should.equal('External Signer Wallet'); + walletBody.enterprise.should.equal('enterprise-id'); + + result.responseType.should.equal('WalletWithKeychains'); + assert.strictEqual(result.userKeychain.pub, userPub); + assert.strictEqual(result.backupKeychain.pub, backupPub); + assert.strictEqual(result.bitgoKeychain.pub, bitgoPub); + }); + + it('should reject when callback source does not match requested source', async function () { + createKeychainCallback.withArgs({ source: 'user', coin: 'tbtc' }).resolves({ + pub: userPub, + type: 'independent', + source: 'backup', + }); + + await wallets + .generateWalletWithExternalSigner({ + label: 'External Signer Wallet', + createKeychainCallback, + }) + .should.be.rejectedWith('createKeychainCallback returned source backup, expected user'); + }); + + it('should reject invalid keyType from callback', async function () { + createKeychainCallback.withArgs({ source: 'user', coin: 'tbtc' }).resolves({ + pub: userPub, + type: 'tss', + source: 'user', + }); + + await wallets + .generateWalletWithExternalSigner({ + label: 'External Signer Wallet', + createKeychainCallback, + }) + .should.be.rejectedWith("createKeychainCallback returned invalid type tss, expected 'independent' for onchain multisig"); + }); + + it('should reject TSS multisig type', async function () { + await wallets + .generateWalletWithExternalSigner({ + label: 'TSS Wallet', + multisigType: 'tss', + createKeychainCallback, + }) + .should.be.rejectedWith('external signer wallet generation is only supported for onchain multisig wallets'); + }); + + it('should reject custodial wallet type', async function () { + await wallets + .generateWalletWithExternalSigner({ + label: 'Custodial Wallet', + type: 'custodial', + createKeychainCallback, + }) + .should.be.rejectedWith('external signer wallet generation is not supported for custodial onchain wallets'); + }); + + it('should reject passcodeEncryptionCode', async function () { + await wallets + .generateWalletWithExternalSigner({ + label: 'Wallet', + passcodeEncryptionCode: 'some-code', + createKeychainCallback, + }) + .should.be.rejectedWith('passcodeEncryptionCode is not supported for external signer wallet generation'); + }); + + it('should reject webauthnInfo', async function () { + await wallets + .generateWalletWithExternalSigner({ + label: 'Wallet', + webauthnInfo: { otpDeviceId: 'dev-id', prfSalt: 'salt', passphrase: 'pass' } as any, + createKeychainCallback, + }) + .should.be.rejectedWith('webauthnInfo is not supported for external signer wallet generation'); + }); + + it('should reject isDistributedCustody without enterprise', async function () { + await wallets + .generateWalletWithExternalSigner({ + label: 'DC Wallet', + type: 'cold', + isDistributedCustody: true, + createKeychainCallback, + }) + .should.be.rejectedWith('must provide enterprise when creating distributed custody wallet'); + }); + + it('should reject isDistributedCustody with non-cold type', async function () { + await wallets + .generateWalletWithExternalSigner({ + label: 'DC Wallet', + type: 'hot', + isDistributedCustody: true, + enterprise: 'enterprise-id', + createKeychainCallback, + }) + .should.be.rejectedWith('distributed custody wallets must be type: cold'); + }); + }); + + describe('generateWallet with createKeychainCallback', function () { + it('should delegate to generateWalletWithExternalSigner', async function () { + const generateWalletWithExternalSignerStub = sinon.stub(wallets, 'generateWalletWithExternalSigner').resolves({ + responseType: 'WalletWithKeychains', + wallet: {} as any, + userKeychain: { id: 'user-key-id', pub: userPub, type: 'independent' } as any, + backupKeychain: { id: 'backup-key-id', pub: backupPub, type: 'independent' } as any, + bitgoKeychain: { id: 'bitgo-key-id', pub: bitgoPub, type: 'independent' } as any, + }); + + await wallets.generateWallet({ + label: 'Delegated Wallet', + createKeychainCallback, + }); + + assert.strictEqual(generateWalletWithExternalSignerStub.calledOnce, true); + generateWalletWithExternalSignerStub.firstCall.args[0].label.should.equal('Delegated Wallet'); + }); + + it('should reject when createKeychainCallback is combined with passphrase', async function () { + await wallets + .generateWallet({ + label: 'Invalid Wallet', + passphrase: 'secret', + createKeychainCallback, + }) + .should.be.rejectedWith('createKeychainCallback cannot be used with passphrase'); + }); + + it('should reject when createKeychainCallback is combined with userKey', async function () { + await wallets + .generateWallet({ + label: 'Invalid Wallet', + userKey: 'xpub...', + createKeychainCallback, + }) + .should.be.rejectedWith('createKeychainCallback cannot be used with userKey'); + }); + }); +});