Skip to content

Commit 1b2470e

Browse files
authored
Merge pull request #8624 from BitGo/WCN-217
feat: implement BitGo signing in SDK
2 parents 6365461 + 7c16a2b commit 1b2470e

12 files changed

Lines changed: 550 additions & 13 deletions

File tree

modules/express/src/clientRoutes.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,15 @@ function getWalletPwFromEnv(walletId: string): string {
405405
return walletPw;
406406
}
407407

408+
/**
409+
* Returns the wallet passphrase from the environment, or undefined if not set.
410+
* Unlike getWalletPwFromEnv, this does not throw when the env variable is absent.
411+
* Use this when the passphrase is optional (e.g. KMS-backed wallets).
412+
*/
413+
function findWalletPwFromEnv(walletId: string): string | undefined {
414+
return process.env[`WALLET_${walletId}_PASSPHRASE`];
415+
}
416+
408417
async function getEncryptedPrivKey(path: string, walletId: string): Promise<string> {
409418
const privKeyFile = await fs.readFile(path, { encoding: 'utf8' });
410419
const encryptedPrivKey = JSON.parse(privKeyFile);
@@ -631,7 +640,9 @@ export async function handleV2OFCSignPayload(
631640
throw new ApiResponseError(`Could not find OFC wallet ${walletId}`, 404);
632641
}
633642

634-
const walletPassphrase = bodyWalletPassphrase || getWalletPwFromEnv(wallet.id());
643+
// Prefer the passphrase from the request body; fall back to the env var.
644+
// If neither is present, pass undefined — signPayload() routes to KMS internally.
645+
const walletPassphrase = bodyWalletPassphrase ?? findWalletPwFromEnv(wallet.id());
635646
const tradingAccount = wallet.toTradingAccount();
636647
const stringifiedPayload = typeof payload === 'string' ? payload : JSON.stringify(payload);
637648
const signature = await tradingAccount.signPayload({

modules/express/test/unit/typedRoutes/ofcSignPayload.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,10 +223,103 @@ describe('OfcSignPayload codec tests', function () {
223223
const decodedResponse = assertDecode(OfcSignPayloadResponse200, result.body);
224224
assert.strictEqual(decodedResponse.signature, mockSignPayloadResponse.signature);
225225

226+
// Verify env passphrase was forwarded to signPayload
227+
const signCall = mockTradingAccount.signPayload.getCall(0);
228+
assert.ok(signCall, 'tradingAccount.signPayload should have been called');
229+
assert.strictEqual(signCall.args[0].walletPassphrase, 'env_passphrase', 'env passphrase should be forwarded');
230+
226231
// Cleanup environment variable
227232
delete process.env['WALLET_ofc-wallet-id-123_PASSPHRASE'];
228233
});
229234

235+
it('should pass undefined walletPassphrase to signPayload when no passphrase in body or env (KMS path)', async function () {
236+
const requestBody = {
237+
walletId: 'ofc-wallet-id-no-passphrase',
238+
payload: { amount: '1000000', currency: 'USD' },
239+
// no walletPassphrase
240+
};
241+
242+
// Ensure no env var is set for this wallet
243+
delete process.env['WALLET_ofc-wallet-id-no-passphrase_PASSPHRASE'];
244+
245+
const mockTradingAccount = {
246+
signPayload: sinon.stub().resolves(mockSignPayloadResponse.signature),
247+
};
248+
249+
const mockWallet = {
250+
id: () => requestBody.walletId,
251+
toTradingAccount: sinon.stub().returns(mockTradingAccount),
252+
};
253+
254+
const walletsGetStub = sinon.stub().resolves(mockWallet);
255+
const mockWallets = { get: walletsGetStub };
256+
const mockCoin = { wallets: sinon.stub().returns(mockWallets) };
257+
sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any);
258+
259+
const result = await agent
260+
.post('/api/v2/ofc/signPayload')
261+
.set('Authorization', 'Bearer test_access_token_12345')
262+
.set('Content-Type', 'application/json')
263+
.send(requestBody);
264+
265+
assert.strictEqual(result.status, 200);
266+
const decodedResponse = assertDecode(OfcSignPayloadResponse200, result.body);
267+
assert.strictEqual(decodedResponse.signature, mockSignPayloadResponse.signature);
268+
269+
// signPayload must be called with walletPassphrase=undefined so the SDK routes to KMS
270+
const signCall = mockTradingAccount.signPayload.getCall(0);
271+
assert.ok(signCall, 'tradingAccount.signPayload should have been called');
272+
assert.strictEqual(
273+
signCall.args[0].walletPassphrase,
274+
undefined,
275+
'walletPassphrase should be undefined to trigger KMS signing'
276+
);
277+
});
278+
279+
it('should prefer body walletPassphrase over env passphrase', async function () {
280+
const requestBody = {
281+
walletId: 'ofc-wallet-id-123',
282+
payload: { amount: '500' },
283+
walletPassphrase: 'body_passphrase',
284+
};
285+
286+
// Set a different env passphrase — body should win
287+
process.env['WALLET_ofc-wallet-id-123_PASSPHRASE'] = 'env_passphrase';
288+
289+
const mockTradingAccount = {
290+
signPayload: sinon.stub().resolves(mockSignPayloadResponse.signature),
291+
};
292+
293+
const mockWallet = {
294+
id: () => requestBody.walletId,
295+
toTradingAccount: sinon.stub().returns(mockTradingAccount),
296+
};
297+
298+
const walletsGetStub = sinon.stub().resolves(mockWallet);
299+
const mockWallets = { get: walletsGetStub };
300+
const mockCoin = { wallets: sinon.stub().returns(mockWallets) };
301+
sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any);
302+
303+
const result = await agent
304+
.post('/api/v2/ofc/signPayload')
305+
.set('Authorization', 'Bearer test_access_token_12345')
306+
.set('Content-Type', 'application/json')
307+
.send(requestBody);
308+
309+
assert.strictEqual(result.status, 200);
310+
311+
// body passphrase should take precedence
312+
const signCall = mockTradingAccount.signPayload.getCall(0);
313+
assert.ok(signCall, 'tradingAccount.signPayload should have been called');
314+
assert.strictEqual(
315+
signCall.args[0].walletPassphrase,
316+
'body_passphrase',
317+
'body passphrase should take precedence over env'
318+
);
319+
320+
delete process.env['WALLET_ofc-wallet-id-123_PASSPHRASE'];
321+
});
322+
230323
it('should successfully sign complex nested JSON payload', async function () {
231324
const requestBody = {
232325
walletId: 'ofc-wallet-id-123',

modules/sdk-core/src/bitgo/trading/iTradingAccount.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import { ITradingNetwork } from './network';
22

3+
/**
4+
* Parameters for the signing a payload from the trading account
5+
* @param payload - The payload to sign
6+
* @param walletPassphrase - The passphrase of the wallet that will be used to decrypt the user key and sign the payload. If not provided, the BitGo key will be used.
7+
*/
38
export interface SignPayloadParameters {
49
payload: string | Record<string, unknown>;
5-
walletPassphrase: string;
10+
walletPassphrase?: string;
611
}
712

813
export interface ITradingAccount {

modules/sdk-core/src/bitgo/trading/network/network.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ export class TradingNetwork implements ITradingNetwork {
109109

110110
/**
111111
* Prepare an allocation for submission
112-
* @param {string} walletPassphrase ofc wallet passphrase
112+
* @param {string} walletPassphrase ofc wallet passphrase - required only when signing via user key
113113
* @param {string} connectionId connection to whom to make the allocation or deallocation
114114
* @param {string=} clientExternalId one time generated uuid v4
115115
* @param {string} currency currency for which the allocation should be made. e.g. btc / tbtc
@@ -130,10 +130,7 @@ export class TradingNetwork implements ITradingNetwork {
130130
}
131131

132132
const payload = JSON.stringify(body);
133-
134-
const prv = await this.wallet.getPrv({ walletPassphrase });
135-
const signedBuffer: Buffer = await this.wallet.baseCoin.signMessage({ prv }, payload);
136-
const signature = signedBuffer.toString('hex');
133+
const signature = await this.wallet.toTradingAccount().signPayload({ payload, walletPassphrase });
137134

138135
return {
139136
...body,

modules/sdk-core/src/bitgo/trading/network/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ export type GetNetworkAllocationByIdResponse = {
125125
};
126126

127127
export type PrepareNetworkAllocationParams = Omit<CreateNetworkAllocationParams, 'payload' | 'signature'> & {
128-
walletPassphrase: string;
128+
walletPassphrase?: string;
129129
clientExternalId?: string;
130130
nonce?: string;
131131
};

modules/sdk-core/src/bitgo/trading/tradingAccount.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,51 @@ export class TradingAccount implements ITradingAccount {
2323
}
2424

2525
/**
26-
* Signs an arbitrary payload with the user key on this trading account
26+
* Signs an arbitrary payload. Use the user key if passphrase is provided, or the BitGo key if not.
2727
* @param params
2828
* @param params.payload arbitrary payload object (string | Record<string, unknown>)
2929
* @param params.walletPassphrase passphrase on this trading account, used to unlock the account user key
3030
* @returns hex-encoded signature of the payload
3131
*/
3232
async signPayload(params: SignPayloadParameters): Promise<string> {
33+
// if no passphrase is provided, attempt to sign using the wallet's bitgo key remotely
34+
if (!params.walletPassphrase) {
35+
return this.signPayloadByBitGoKey(params);
36+
}
37+
// if a passphrase is provided, we must be trying to sign using the user private key - decrypt and sign locally
38+
return this.signPayloadByUserKey(params);
39+
}
40+
41+
/**
42+
* Signs the payload of a trading account via the trading account BitGo key
43+
* @param params
44+
* @private
45+
*/
46+
private async signPayloadByBitGoKey(params: Omit<SignPayloadParameters, 'walletPassphrase'>): Promise<string> {
47+
const walletData = this.wallet.toJSON();
48+
if (walletData.userKeySigningRequired) {
49+
throw new Error(
50+
'Wallet must use user key to sign ofc transaction, please provide the wallet passphrase or visit your wallet settings page to configure one.'
51+
);
52+
}
53+
if (walletData.keys.length < 2) {
54+
throw new Error(
55+
'Wallet does not support BitGo signing. Please reach out to support@bitgo.com to resolve this issue.'
56+
);
57+
}
58+
59+
const url = this.wallet.url('/tx/sign');
60+
const { signature } = await this.wallet.bitgo.post(url).send(params.payload).result();
61+
62+
return signature;
63+
}
64+
65+
/**
66+
* Signs the payload of a trading account locally by fetching the user's encrypted private key and decrypt using passphrase
67+
* @param params
68+
* @private
69+
*/
70+
private async signPayloadByUserKey(params: SignPayloadParameters): Promise<string> {
3371
const key = (await this.wallet.baseCoin.keychains().get({ id: this.wallet.keyIds()[0] })) as any;
3472
const prv = this.wallet.bitgo.decrypt({
3573
input: key.encryptedPrv,

modules/sdk-core/src/bitgo/wallet/iWallet.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -909,6 +909,7 @@ export interface WalletData {
909909
evmKeyRingReferenceWalletId?: string;
910910
isParent?: boolean;
911911
enabledChildChains?: string[];
912+
userKeySigningRequired?: string;
912913
}
913914

914915
export interface RecoverTokenOptions {

modules/sdk-core/src/coins/ofc.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
SignTransactionOptions,
1616
VerifyAddressOptions,
1717
VerifyTransactionOptions,
18+
Wallet,
1819
} from '../';
1920

2021
export class Ofc extends BaseCoin {
@@ -104,6 +105,26 @@ export class Ofc extends BaseCoin {
104105
throw new MethodNotImplementedError();
105106
}
106107

108+
/**
109+
* Signs a message using a trading wallet's BitGo Key
110+
* @param wallet - uses the BitGo key of this trading wallet to sign the message remotely in a KMS
111+
* @param message
112+
*/
113+
async signMessage(wallet: Wallet, message: string): Promise<Buffer>;
114+
/**
115+
* Signs a message using the private key
116+
* @param key - uses the private key to sign the message
117+
* @param message
118+
*/
119+
async signMessage(key: { prv: string }, message: string): Promise<Buffer>;
120+
async signMessage(keyOrWallet: { prv: string } | Wallet, message: string): Promise<Buffer> {
121+
if (!(keyOrWallet instanceof Wallet)) {
122+
return super.signMessage(keyOrWallet, message);
123+
}
124+
const signatureHexString = await keyOrWallet.toTradingAccount().signPayload({ payload: message });
125+
return Buffer.from(signatureHexString, 'hex');
126+
}
127+
107128
/** @inheritDoc */
108129
auditDecryptedKey(params: AuditDecryptedKeyParams) {
109130
throw new MethodNotImplementedError();

modules/sdk-core/src/coins/ofcToken.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
SignTransactionOptions as BaseSignTransactionOptions,
1010
SignedTransaction,
1111
ITransactionRecipient,
12+
Wallet,
1213
} from '../';
1314
import { isBolt11Invoice } from '../lightning';
1415

@@ -18,7 +19,8 @@ export interface SignTransactionOptions extends BaseSignTransactionOptions {
1819
txPrebuild: {
1920
payload: string;
2021
};
21-
prv: string;
22+
prv?: string;
23+
wallet?: Wallet;
2224
}
2325

2426
export { OfcTokenConfig };
@@ -107,15 +109,25 @@ export class OfcToken extends Ofc {
107109
}
108110

109111
/**
110-
* Assemble keychain and half-sign prebuilt transaction
112+
* Signs a half-signed OFC transaction.
113+
* Signs the transaction remotely using the BitGo key if prv is not provided.
111114
* @param params
112115
* @returns {Promise<SignedTransaction>}
113116
*/
114117
async signTransaction(params: SignTransactionOptions): Promise<SignedTransaction> {
115118
const txPrebuild = params.txPrebuild;
116119
const payload = txPrebuild.payload;
117-
const signatureBuffer = (await this.signMessage(params, payload)) as any;
118-
const signature: string = signatureBuffer.toString('hex');
120+
121+
let signature: string;
122+
if (params.wallet) {
123+
signature = await params.wallet.toTradingAccount().signPayload({ payload, walletPassphrase: params.prv });
124+
} else if (params.prv) {
125+
const signatureBuffer = (await this.signMessage({ prv: params.prv }, payload)) as any;
126+
signature = signatureBuffer.toString('hex');
127+
} else {
128+
throw new Error('You must pass in either one of wallet or prv');
129+
}
130+
119131
return { halfSigned: { payload, signature } } as any;
120132
}
121133

0 commit comments

Comments
 (0)