diff --git a/modules/sdk-core/src/bitgo/wallet/iWallets.ts b/modules/sdk-core/src/bitgo/wallet/iWallets.ts index ac82fc56bd..aee366b40b 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: 'user' | 'backup'; +} + +export type CreateKeychainCallback = (params: CreateKeychainCallbackParams) => Promise; + export interface GenerateWalletOptions { label?: string; passphrase?: string; @@ -95,6 +108,16 @@ 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; + /** Optional user-key signatures over backup/bitgo pubs. Omit when the external signer cannot produce them (equivalent to a cold wallet). */ + keySignatures?: { backup: string; bitgo: string }; } export const GenerateLightningWalletOptionsCodec = t.intersection( @@ -281,6 +304,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..4f7faf4551 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallets.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallets.ts @@ -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,181 @@ 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'); + } + + // these belong to the passphrase-based path and are incompatible with createKeychainCallback + const passphrasePathParams = ['passphrase', 'userKey', 'backupXpub', 'backupXpubProvider'] as const; + for (const key of passphrasePathParams) { + 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 => { + try { + const keychainFromCallback = await createKeychainCallback({ source, coin }); + if (keychainFromCallback.source !== source) { + throw new Error(`createKeychainCallback returned source ${keychainFromCallback.source}, expected ${source}`); + } + if (keychainFromCallback.type !== 'independent') { + throw new Error( + `createKeychainCallback returned invalid type ${keychainFromCallback.type}, expected 'independent' for onchain multisig` + ); + } + if (!this.baseCoin.isValidPub(keychainFromCallback.pub)) { + throw new Error(`createKeychainCallback returned invalid pub for ${source} key on ${coin}`); + } + return this.baseCoin.keychains().add({ + pub: keychainFromCallback.pub, + keyType: keychainFromCallback.type, + source: keychainFromCallback.source, + reqId, + }); + } catch (error) { + throw new Error( + `Failed to create ${source} keychain: ${error instanceof Error ? error.message : String(error)}` + ); + } + }; + + const { userKeychain, backupKeychain, bitgoKeychain }: KeychainsTriplet = await promiseProps({ + userKeychain: createAndUploadKeychain('user'), + backupKeychain: createAndUploadKeychain('backup'), + bitgoKeychain: this.baseCoin + .keychains() + .createBitGo({ enterprise, reqId, isDistributedCustody: params.isDistributedCustody }), + }); + + walletParams.keys = [userKeychain.id, backupKeychain.id, bitgoKeychain.id]; + + if (params.keySignatures) { + walletParams.keySignatures = params.keySignatures; + } + + 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..428a197918 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/wallet/walletsExternalSigner.ts @@ -0,0 +1,328 @@ +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'; +import { Wallet } from '../../../../src/bitgo/wallet/wallet'; + +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)), + isValidPub: sinon.stub().returns(true), + }; + + 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( + 'Failed to create user keychain: createKeychainCallback returned source backup, expected user' + ); + }); + + it('should reject invalid type from callback', async function () { + const badTypeCallback = sinon.stub(); + badTypeCallback.withArgs({ source: 'user', coin: 'tbtc' }).resolves({ + pub: userPub, + type: 'tss', + source: 'user', + }); + badTypeCallback.withArgs({ source: 'backup', coin: 'tbtc' }).resolves({ + pub: backupPub, + type: 'independent', + source: 'backup', + }); + + await wallets + .generateWalletWithExternalSigner({ + label: 'External Signer Wallet', + createKeychainCallback: badTypeCallback, + }) + .should.be.rejectedWith( + "Failed to create user keychain: 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' }, + 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'); + }); + + it('should reject when callback returns invalid pub for coin', async function () { + // only invalidate user pub so the rejection source is deterministic + mockBaseCoin.isValidPub.callsFake((pub: string) => pub !== userPub); + + await wallets + .generateWalletWithExternalSigner({ + label: 'External Signer Wallet', + createKeychainCallback, + }) + .should.be.rejectedWith( + 'Failed to create user keychain: createKeychainCallback returned invalid pub for user key on tbtc' + ); + }); + + it('should wrap error when callback throws', async function () { + createKeychainCallback.withArgs({ source: 'user', coin: 'tbtc' }).rejects(new Error('HSM unreachable')); + + await wallets + .generateWalletWithExternalSigner({ + label: 'External Signer Wallet', + createKeychainCallback, + }) + .should.be.rejectedWith('Failed to create user keychain: HSM unreachable'); + }); + + it('should reject with backup keychain error when backup callback throws', async function () { + createKeychainCallback.withArgs({ source: 'backup', coin: 'tbtc' }).rejects(new Error('HSM unreachable')); + + await wallets + .generateWalletWithExternalSigner({ + label: 'External Signer Wallet', + createKeychainCallback, + }) + .should.be.rejectedWith('Failed to create backup keychain: HSM unreachable'); + + assert.strictEqual(mockBitGo.post.callCount, 0); + }); + + it('should wrap non-Error thrown by callback', async function () { + createKeychainCallback + .withArgs({ source: 'user', coin: 'tbtc' }) + .returns(Promise.reject('plain string rejection')); + + await wallets + .generateWalletWithExternalSigner({ + label: 'External Signer Wallet', + createKeychainCallback, + }) + .should.be.rejectedWith('Failed to create user keychain: plain string rejection'); + }); + + it('should forward keySignatures to wallet params when provided', async function () { + const keySignatures = { + backup: 'deadbeef01', + bitgo: 'deadbeef02', + }; + + await wallets.generateWalletWithExternalSigner({ + label: 'External Signer Wallet', + enterprise: 'enterprise-id', + createKeychainCallback, + keySignatures, + }); + + const walletBody = sendStub.firstCall.args[0]; + walletBody.should.have.property('keySignatures'); + walletBody.keySignatures.should.deepEqual(keySignatures); + }); + + it('should not include keySignatures in wallet params when not provided', async function () { + await wallets.generateWalletWithExternalSigner({ + label: 'External Signer Wallet', + enterprise: 'enterprise-id', + createKeychainCallback, + }); + + const walletBody = sendStub.firstCall.args[0]; + walletBody.should.not.have.property('keySignatures'); + }); + }); + + describe('generateWallet with createKeychainCallback', function () { + it('should delegate to generateWalletWithExternalSigner', async function () { + const generateWalletWithExternalSignerStub = sinon.stub(wallets, 'generateWalletWithExternalSigner').resolves({ + responseType: 'WalletWithKeychains', + wallet: sinon.createStubInstance(Wallet), + userKeychain: { id: 'user-key-id', pub: userPub, type: 'independent' as const }, + backupKeychain: { id: 'backup-key-id', pub: backupPub, type: 'independent' as const }, + bitgoKeychain: { id: 'bitgo-key-id', pub: bitgoPub, type: 'independent' as const }, + }); + + 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'); + }); + }); +});