Skip to content
Open
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
13 changes: 12 additions & 1 deletion modules/express/src/clientRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,15 @@ function getWalletPwFromEnv(walletId: string): string {
return walletPw;
}

/**
* Returns the wallet passphrase from the environment, or undefined if not set.
* Unlike getWalletPwFromEnv, this does not throw when the env variable is absent.
* Use this when the passphrase is optional (e.g. KMS-backed wallets).
*/
function findWalletPwFromEnv(walletId: string): string | undefined {
return process.env[`WALLET_${walletId}_PASSPHRASE`];
}

async function getEncryptedPrivKey(path: string, walletId: string): Promise<string> {
const privKeyFile = await fs.readFile(path, { encoding: 'utf8' });
const encryptedPrivKey = JSON.parse(privKeyFile);
Expand Down Expand Up @@ -631,7 +640,9 @@ export async function handleV2OFCSignPayload(
throw new ApiResponseError(`Could not find OFC wallet ${walletId}`, 404);
}

const walletPassphrase = bodyWalletPassphrase || getWalletPwFromEnv(wallet.id());
// Prefer the passphrase from the request body; fall back to the env var.
// If neither is present, pass undefined — signPayload() routes to KMS internally.
const walletPassphrase = bodyWalletPassphrase ?? findWalletPwFromEnv(wallet.id());
const tradingAccount = wallet.toTradingAccount();
const stringifiedPayload = typeof payload === 'string' ? payload : JSON.stringify(payload);
const signature = await tradingAccount.signPayload({
Expand Down
93 changes: 93 additions & 0 deletions modules/express/test/unit/typedRoutes/ofcSignPayload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,10 +223,103 @@ describe('OfcSignPayload codec tests', function () {
const decodedResponse = assertDecode(OfcSignPayloadResponse200, result.body);
assert.strictEqual(decodedResponse.signature, mockSignPayloadResponse.signature);

// Verify env passphrase was forwarded to signPayload
const signCall = mockTradingAccount.signPayload.getCall(0);
assert.ok(signCall, 'tradingAccount.signPayload should have been called');
assert.strictEqual(signCall.args[0].walletPassphrase, 'env_passphrase', 'env passphrase should be forwarded');

// Cleanup environment variable
delete process.env['WALLET_ofc-wallet-id-123_PASSPHRASE'];
});

it('should pass undefined walletPassphrase to signPayload when no passphrase in body or env (KMS path)', async function () {
const requestBody = {
walletId: 'ofc-wallet-id-no-passphrase',
payload: { amount: '1000000', currency: 'USD' },
// no walletPassphrase
};

// Ensure no env var is set for this wallet
delete process.env['WALLET_ofc-wallet-id-no-passphrase_PASSPHRASE'];

const mockTradingAccount = {
signPayload: sinon.stub().resolves(mockSignPayloadResponse.signature),
};

const mockWallet = {
id: () => requestBody.walletId,
toTradingAccount: sinon.stub().returns(mockTradingAccount),
};

const walletsGetStub = sinon.stub().resolves(mockWallet);
const mockWallets = { get: walletsGetStub };
const mockCoin = { wallets: sinon.stub().returns(mockWallets) };
sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any);

const result = await agent
.post('/api/v2/ofc/signPayload')
.set('Authorization', 'Bearer test_access_token_12345')
.set('Content-Type', 'application/json')
.send(requestBody);

assert.strictEqual(result.status, 200);
const decodedResponse = assertDecode(OfcSignPayloadResponse200, result.body);
assert.strictEqual(decodedResponse.signature, mockSignPayloadResponse.signature);

// signPayload must be called with walletPassphrase=undefined so the SDK routes to KMS
const signCall = mockTradingAccount.signPayload.getCall(0);
assert.ok(signCall, 'tradingAccount.signPayload should have been called');
assert.strictEqual(
signCall.args[0].walletPassphrase,
undefined,
'walletPassphrase should be undefined to trigger KMS signing'
);
});

it('should prefer body walletPassphrase over env passphrase', async function () {
const requestBody = {
walletId: 'ofc-wallet-id-123',
payload: { amount: '500' },
walletPassphrase: 'body_passphrase',
};

// Set a different env passphrase — body should win
process.env['WALLET_ofc-wallet-id-123_PASSPHRASE'] = 'env_passphrase';

const mockTradingAccount = {
signPayload: sinon.stub().resolves(mockSignPayloadResponse.signature),
};

const mockWallet = {
id: () => requestBody.walletId,
toTradingAccount: sinon.stub().returns(mockTradingAccount),
};

const walletsGetStub = sinon.stub().resolves(mockWallet);
const mockWallets = { get: walletsGetStub };
const mockCoin = { wallets: sinon.stub().returns(mockWallets) };
sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any);

const result = await agent
.post('/api/v2/ofc/signPayload')
.set('Authorization', 'Bearer test_access_token_12345')
.set('Content-Type', 'application/json')
.send(requestBody);

assert.strictEqual(result.status, 200);

// body passphrase should take precedence
const signCall = mockTradingAccount.signPayload.getCall(0);
assert.ok(signCall, 'tradingAccount.signPayload should have been called');
assert.strictEqual(
signCall.args[0].walletPassphrase,
'body_passphrase',
'body passphrase should take precedence over env'
);

delete process.env['WALLET_ofc-wallet-id-123_PASSPHRASE'];
});

it('should successfully sign complex nested JSON payload', async function () {
const requestBody = {
walletId: 'ofc-wallet-id-123',
Expand Down
13 changes: 12 additions & 1 deletion modules/sdk-core/src/bitgo/trading/iTradingAccount.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
import { ITradingNetwork } from './network';

/**
* Parameters for the signing a payload from the trading account.
* If both walletPassphrase and prv are not provided, the BitGo key will be used.
*
* @note If wallet has userKeySigningRequired set to true, then attempting to sign with BitGo key will throw.
*
* @param payload - The payload to sign
* @param walletPassphrase - The passphrase of the wallet that will be used to decrypt the user key and sign the payload.
* @param prv - The decrypted user key prv used to sign the payload
*/
export interface SignPayloadParameters {
payload: string | Record<string, unknown>;
walletPassphrase: string;
walletPassphrase?: string;
prv?: string;
Comment thread
zahin-mohammad marked this conversation as resolved.
}

export interface ITradingAccount {
Expand Down
7 changes: 2 additions & 5 deletions modules/sdk-core/src/bitgo/trading/network/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ export class TradingNetwork implements ITradingNetwork {

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

const payload = JSON.stringify(body);

const prv = await this.wallet.getPrv({ walletPassphrase });
const signedBuffer: Buffer = await this.wallet.baseCoin.signMessage({ prv }, payload);
const signature = signedBuffer.toString('hex');
const signature = await this.wallet.toTradingAccount().signPayload({ payload, walletPassphrase });
Comment thread
alextse-bg marked this conversation as resolved.

return {
...body,
Expand Down
2 changes: 1 addition & 1 deletion modules/sdk-core/src/bitgo/trading/network/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ export type GetNetworkAllocationByIdResponse = {
};

export type PrepareNetworkAllocationParams = Omit<CreateNetworkAllocationParams, 'payload' | 'signature'> & {
walletPassphrase: string;
walletPassphrase?: string;
clientExternalId?: string;
nonce?: string;
};
Expand Down
71 changes: 64 additions & 7 deletions modules/sdk-core/src/bitgo/trading/tradingAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,20 +23,77 @@ export class TradingAccount implements ITradingAccount {
}

/**
* Signs an arbitrary payload with the user key on this trading account
* Signs an arbitrary payload. Use the user key if passphrase/prv is provided, or the BitGo key if not.
* @param params
* @param params.payload arbitrary payload object (string | Record<string, unknown>)
* @param params.walletPassphrase passphrase on this trading account, used to unlock the account user key
* @param params.prv user private key, used to sign the payload locally
* @returns hex-encoded signature of the payload
*/
async signPayload(params: SignPayloadParameters): Promise<string> {
const key = (await this.wallet.baseCoin.keychains().get({ id: this.wallet.keyIds()[0] })) as any;
const prv = this.wallet.bitgo.decrypt({
input: key.encryptedPrv,
password: params.walletPassphrase,
});
// if no passphrase is provided, attempt to sign using the wallet's bitgo key remotely
if (!params.walletPassphrase && !params.prv) {
return this.signPayloadByBitGoKey(params);
}
// if a passphrase is provided, we must be trying to sign using the user private key - decrypt and sign locally
return this.signPayloadByUserKey(params);
}

/**
* Signs the payload of a trading account via the trading account BitGo key
* @param params
* @private
*/
private async signPayloadByBitGoKey(
params: Omit<SignPayloadParameters, 'walletPassphrase' | 'prv'>
): Promise<string> {
const walletData = this.wallet.toJSON();
if (walletData.userKeySigningRequired) {
throw new Error(
'Wallet must use user key to sign ofc transaction, please provide the wallet passphrase or visit your wallet settings page to configure one.'
);
}
if (walletData.keys.length < 2) {
throw new Error(
'Wallet does not support BitGo signing. Please reach out to support@bitgo.com to resolve this issue.'
);
}

// we do not parse the payload here, we instead sends the payload as a stringified JSON to be signed, just like how we process it locally
const url = this.wallet.url('/tx/sign');
const payload = typeof params.payload !== 'string' ? JSON.stringify(params.payload) : params.payload;
const { signature } = await this.wallet.bitgo.post(url).send({ payload }).result();

return signature;
}

/**
* Signs the payload of a trading account locally by fetching the user's encrypted private key and decrypt using passphrase
* @param params
* @private
*/
private async signPayloadByUserKey(params: SignPayloadParameters): Promise<string> {
if (!params.prv && !params.walletPassphrase) {
throw new Error(
'Must provide either prv or walletPassphrase to sign payload using user key. Please provide the wallet passphrase or visit your wallet settings page to configure one.'
);
}

let prv: string;
if (params.prv) {
prv = params.prv;
} else {
const key = await this.wallet.baseCoin.keychains().get({ id: this.wallet.keyIds()[0] });
if (!key.encryptedPrv) {
throw new Error('Expected encryptedPrv to be present on user keychain.');
}
prv = this.wallet.bitgo.decrypt({
input: key.encryptedPrv,
password: params.walletPassphrase,
});
}
const payload = typeof params.payload === 'string' ? params.payload : JSON.stringify(params.payload);
return ((await this.wallet.baseCoin.signMessage({ prv }, payload)) as any).toString('hex');
return (await this.wallet.baseCoin.signMessage({ prv }, payload)).toString('hex');
}

/**
Expand Down
1 change: 1 addition & 0 deletions modules/sdk-core/src/bitgo/wallet/iWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -909,6 +909,7 @@ export interface WalletData {
evmKeyRingReferenceWalletId?: string;
isParent?: boolean;
enabledChildChains?: string[];
userKeySigningRequired?: boolean;
}

export interface RecoverTokenOptions {
Expand Down
Loading
Loading