diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index 9ebac5fc16..8037018182 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -539,7 +539,7 @@ export async function handleV2Sign(req: ExpressApiRouteRequest<'express.v2.coin. } export async function handleV2OFCSignPayloadInExtSigningMode( - req: express.Request + req: ExpressApiRouteRequest<'express.v2.ofc.extSignPayload', 'post'> ): Promise<{ payload: string; signature: string }> { const walletId = req.body.walletId; const payload = req.body.payload; @@ -1741,12 +1741,10 @@ export function setupSigningRoutes(app: express.Application, config: Config): vo prepareBitGo(config), promiseWrapper(handleV2GenerateShareTSS) ); - app.post( - `/api/v2/ofc/signPayload`, - parseBody, + router.post('express.v2.ofc.extSignPayload', [ prepareBitGo(config), - promiseWrapper(handleV2OFCSignPayloadInExtSigningMode) - ); + typedPromiseWrapper(handleV2OFCSignPayloadInExtSigningMode), + ]); } export function setupLightningSignerNodeRoutes(app: express.Application, config: Config): void { diff --git a/modules/express/src/typedRoutes/api/index.ts b/modules/express/src/typedRoutes/api/index.ts index 7de466acff..f834a375c2 100644 --- a/modules/express/src/typedRoutes/api/index.ts +++ b/modules/express/src/typedRoutes/api/index.ts @@ -39,6 +39,7 @@ import { PostConsolidateUnspents } from './v2/consolidateunspents'; import { PostPrebuildAndSignTransaction } from './v2/prebuildAndSignTransaction'; import { PostCoinSign } from './v2/coinSign'; import { PostSendCoins } from './v2/sendCoins'; +import { PostOfcExtSignPayload } from './v2/ofcExtSignPayload'; // Too large types can cause the following error // @@ -227,6 +228,9 @@ export const ExpressExternalSigningApiSpec = apiSpec({ 'express.v2.coin.sign': { post: PostCoinSign, }, + 'express.v2.ofc.extSignPayload': { + post: PostOfcExtSignPayload, + }, }); export const ExpressWalletSigningApiSpec = apiSpec({ diff --git a/modules/express/src/typedRoutes/api/v2/ofcExtSignPayload.ts b/modules/express/src/typedRoutes/api/v2/ofcExtSignPayload.ts new file mode 100644 index 0000000000..148d9b008a --- /dev/null +++ b/modules/express/src/typedRoutes/api/v2/ofcExtSignPayload.ts @@ -0,0 +1,36 @@ +import { httpRoute, httpRequest } from '@api-ts/io-ts-http'; +import { OfcSignPayloadBody, OfcSignPayloadResponse } from './ofcSignPayload'; + +/** + * Sign an arbitrary payload using an OFC trading account key (External Signing Mode). + * + * This endpoint is used when BitGo Express is running in external signing mode, + * where private keys are stored in an encrypted file on the filesystem rather than + * being fetched from the BitGo API. + * + * The request and response structure is identical to the regular OFC sign payload endpoint, + * but the implementation reads the encrypted private key from a local file specified by + * the `signerFileSystemPath` configuration. + * + * **External Signing Mode Requirements**: + * - `signerFileSystemPath` must be configured in Express config + * - Encrypted private keys must be available in the file system + * - Wallet passphrase must be provided via request body or environment variable + * + * **Flow**: + * 1. Reads encrypted private key from filesystem + * 2. Decrypts private key using wallet passphrase + * 3. Signs the payload with the decrypted key + * 4. Returns signed payload and hex-encoded signature + * + * @operationId express.v2.ofc.extSignPayload + * @tag express + */ +export const PostOfcExtSignPayload = httpRoute({ + path: '/api/v2/ofc/signPayload', + method: 'POST', + request: httpRequest({ + body: OfcSignPayloadBody, + }), + response: OfcSignPayloadResponse, +}); diff --git a/modules/express/test/unit/clientRoutes/signPayload.ts b/modules/express/test/unit/clientRoutes/signPayload.ts index 682d95ce0f..b239ff537a 100644 --- a/modules/express/test/unit/clientRoutes/signPayload.ts +++ b/modules/express/test/unit/clientRoutes/signPayload.ts @@ -3,7 +3,6 @@ import 'should-http'; import 'should-sinon'; import 'should'; import * as fs from 'fs'; -import { Request } from 'express'; import { BitGo, Coin, BaseCoin, Wallet, Wallets } from 'bitgo'; import '../../lib/asserts'; import { handleV2OFCSignPayload, handleV2OFCSignPayloadInExtSigningMode } from '../../../src/clientRoutes'; @@ -158,10 +157,14 @@ describe('With the handler to sign an arbitrary payload in external signing mode walletId, payload, }, + decoded: { + walletId, + payload, + }, config: { signerFileSystemPath: 'signerFileSystemPath', }, - } as unknown as Request; + } as unknown as ExpressApiRouteRequest<'express.v2.ofc.extSignPayload', 'post'>; await handleV2OFCSignPayloadInExtSigningMode(req).should.be.resolvedWith(expectedResponse); readFileStub.should.be.calledOnceWith('signerFileSystemPath'); @@ -195,10 +198,15 @@ describe('With the handler to sign an arbitrary payload in external signing mode payload, walletPassphrase: walletPassword, }, + decoded: { + walletId, + payload, + walletPassphrase: walletPassword, + }, config: { signerFileSystemPath: 'signerFileSystemPath', }, - } as unknown as Request; + } as unknown as ExpressApiRouteRequest<'express.v2.ofc.extSignPayload', 'post'>; await handleV2OFCSignPayloadInExtSigningMode(req).should.be.resolvedWith(expectedResponse); readFileStub.should.be.calledOnceWith('signerFileSystemPath'); @@ -233,10 +241,15 @@ describe('With the handler to sign an arbitrary payload in external signing mode payload, walletPassphrase: walletPassword, }, + decoded: { + walletId, + payload, + walletPassphrase: walletPassword, + }, config: { signerFileSystemPath: 'signerFileSystemPath', }, - } as unknown as Request; + } as unknown as ExpressApiRouteRequest<'express.v2.ofc.extSignPayload', 'post'>; await handleV2OFCSignPayloadInExtSigningMode(req).should.be.resolvedWith(expectedResponse); readFileStub.should.be.calledOnceWith('signerFileSystemPath'); @@ -260,7 +273,11 @@ describe('With the handler to sign an arbitrary payload in external signing mode walletId, payload, }, - } as unknown as Request; + decoded: { + walletId, + payload, + }, + } as unknown as ExpressApiRouteRequest<'express.v2.ofc.extSignPayload', 'post'>; await handleV2OFCSignPayloadInExtSigningMode(req).should.be.rejectedWith( 'Could not find wallet passphrase WALLET_61f039aad587c2000745c687373e0fa9_PASSPHRASE in environment' @@ -278,10 +295,14 @@ describe('With the handler to sign an arbitrary payload in external signing mode walletId, payload, }, + decoded: { + walletId, + payload, + }, config: { signerFileSystemPath: undefined, }, - } as unknown as Request; + } as unknown as ExpressApiRouteRequest<'express.v2.ofc.extSignPayload', 'post'>; await handleV2OFCSignPayloadInExtSigningMode(req).should.be.rejectedWith( 'Missing required configuration: signerFileSystemPath' @@ -301,10 +322,14 @@ describe('With the handler to sign an arbitrary payload in external signing mode walletId, payload, }, + decoded: { + walletId, + payload, + }, config: { signerFileSystemPath: 'signerFileSystemPath', }, - } as unknown as Request; + } as unknown as ExpressApiRouteRequest<'express.v2.ofc.extSignPayload', 'post'>; await handleV2OFCSignPayloadInExtSigningMode(req).should.be.rejectedWith( "Error when trying to decrypt private key: INVALID: json decode: this isn't json!" @@ -326,10 +351,14 @@ describe('With the handler to sign an arbitrary payload in external signing mode walletId, payload, }, + decoded: { + walletId, + payload, + }, config: { signerFileSystemPath: 'signerFileSystemPath', }, - } as unknown as Request; + } as unknown as ExpressApiRouteRequest<'express.v2.ofc.extSignPayload', 'post'>; await handleV2OFCSignPayloadInExtSigningMode(req).should.be.rejectedWith( "Error when trying to decrypt private key: CORRUPT: password error - ccm: tag doesn't match" @@ -349,10 +378,15 @@ describe('With the handler to sign an arbitrary payload in external signing mode payload, walletPassphrase: 'invalidPassphrase', }, + decoded: { + walletId, + payload, + walletPassphrase: 'invalidPassphrase', + }, config: { signerFileSystemPath: 'signerFileSystemPath', }, - } as unknown as Request; + } as unknown as ExpressApiRouteRequest<'express.v2.ofc.extSignPayload', 'post'>; await handleV2OFCSignPayloadInExtSigningMode(req).should.be.rejectedWith( "Error when trying to decrypt private key: CORRUPT: password error - ccm: tag doesn't match" diff --git a/modules/express/test/unit/typedRoutes/ofcExtSignPayload.ts b/modules/express/test/unit/typedRoutes/ofcExtSignPayload.ts new file mode 100644 index 0000000000..43475c62cf --- /dev/null +++ b/modules/express/test/unit/typedRoutes/ofcExtSignPayload.ts @@ -0,0 +1,349 @@ +import * as assert from 'assert'; +import { OfcSignPayloadResponse200 } from '../../../src/typedRoutes/api/v2/ofcSignPayload'; +import { PostOfcExtSignPayload } from '../../../src/typedRoutes/api/v2/ofcExtSignPayload'; +import { assertDecode } from './common'; +import 'should'; +import 'should-http'; +import 'should-sinon'; +import * as sinon from 'sinon'; +import { BitGo } from 'bitgo'; +import { promises as fs } from 'fs'; +import * as fsSync from 'fs'; +import { setupAgent } from '../../lib/testutil'; + +describe('OfcExtSignPayload External Signer Mode Tests', function () { + describe('ofcExtSignPayload', function () { + const walletId = 'ofc-wallet-ext-id-123'; + const encryptedPrivKey = 'xprvA1KNMoDNPEsKcNu7Lf5hUVp5L3f9qfH9DpW5L3f9qfH9DpW5L3f9qfH9DpW5:encrypted'; + const decryptedPrivKey = 'xprvA1KNMoDNPEsKcNu7Lf5hUVp5L3f9qfH9DpW5'; + const walletPassphrase = 'test_wallet_passphrase_ofc'; + + const path = require('path'); + const signerFilePath = path.join(__dirname, '../../../encryptedPrivKeys.json'); + + let fsReadFileStub: sinon.SinonStub; + let agent: ReturnType; + let originalFileContent: string; + + const mockSignerFileContent = JSON.stringify({ + [walletId]: encryptedPrivKey, + }); + + const mockSignPayloadResponse = { + payload: '{"amount":"1000000","currency":"USD","recipient":"0xabcdefabcdef"}', + signature: + '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef12', + }; + + before(function () { + try { + originalFileContent = fsSync.readFileSync(signerFilePath, 'utf8'); + } catch (e) { + originalFileContent = '{}'; + } + + fsSync.writeFileSync(signerFilePath, mockSignerFileContent); + + agent = setupAgent({ + signerMode: true, + signerFileSystemPath: signerFilePath, + }); + }); + + after(function () { + fsSync.writeFileSync(signerFilePath, originalFileContent); + }); + + beforeEach(function () { + process.env[`WALLET_${walletId}_PASSPHRASE`] = walletPassphrase; + }); + + afterEach(function () { + sinon.restore(); + delete process.env[`WALLET_${walletId}_PASSPHRASE`]; + }); + + it('should successfully sign payload in external signer mode', async function () { + const requestBody = { + walletId: walletId, + payload: { amount: '1000000', currency: 'USD', recipient: '0xabcdefabcdef' }, + walletPassphrase: walletPassphrase, + }; + + fsReadFileStub = sinon.stub(fs, 'readFile').resolves( + JSON.stringify({ + [walletId]: encryptedPrivKey, + }) + ); + + const mockSignMessageResult = Buffer.from(mockSignPayloadResponse.signature.substring(2), 'hex'); + const mockCoin = { + signMessage: sinon.stub().resolves(mockSignMessageResult), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + sinon.stub(BitGo.prototype, 'decrypt').returns(decryptedPrivKey); + + 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); + result.body.should.have.property('payload'); + result.body.should.have.property('signature'); + + const decodedResponse = assertDecode(OfcSignPayloadResponse200, result.body); + assert.strictEqual(typeof decodedResponse.payload, 'string'); + assert.strictEqual(typeof decodedResponse.signature, 'string'); + + // Verify external signing mode operations + assert.strictEqual(fsReadFileStub.calledOnce, true); + const decryptStub = BitGo.prototype.decrypt as sinon.SinonStub; + assert.strictEqual(decryptStub.calledOnce, true); + assert.strictEqual(decryptStub.calledWith({ password: walletPassphrase, input: encryptedPrivKey }), true); + assert.strictEqual(mockCoin.signMessage.calledOnce, true); + assert.strictEqual(mockCoin.signMessage.firstCall.args[0].prv, decryptedPrivKey); + }); + + it('should successfully sign with stringified JSON payload', async function () { + const requestBody = { + walletId: walletId, + payload: '{"transaction":"data","amount":1000}', + walletPassphrase: walletPassphrase, + }; + + fsReadFileStub = sinon.stub(fs, 'readFile').resolves( + JSON.stringify({ + [walletId]: encryptedPrivKey, + }) + ); + + const mockSignMessageResult = Buffer.from(mockSignPayloadResponse.signature.substring(2), 'hex'); + const mockCoin = { + signMessage: sinon.stub().resolves(mockSignMessageResult), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + sinon.stub(BitGo.prototype, 'decrypt').returns(decryptedPrivKey); + + 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.ok(decodedResponse); + + // Verify payload stayed as string + const signMessageCall = mockCoin.signMessage.firstCall.args; + assert.strictEqual(signMessageCall[1], requestBody.payload); + }); + + it('should successfully sign without walletPassphrase (uses env)', async function () { + const requestBody = { + walletId: walletId, + payload: { amount: '1000000', currency: 'USD' }, + }; + + fsReadFileStub = sinon.stub(fs, 'readFile').resolves( + JSON.stringify({ + [walletId]: encryptedPrivKey, + }) + ); + + const mockSignMessageResult = Buffer.from(mockSignPayloadResponse.signature.substring(2), 'hex'); + const mockCoin = { + signMessage: sinon.stub().resolves(mockSignMessageResult), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + sinon.stub(BitGo.prototype, 'decrypt').returns(decryptedPrivKey); + + 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.ok(decodedResponse.signature); + + // Verify decrypt was called with env passphrase + const decryptStub = BitGo.prototype.decrypt as sinon.SinonStub; + assert.strictEqual(decryptStub.calledWith({ password: walletPassphrase, input: encryptedPrivKey }), true); + }); + + describe('Error Cases', function () { + it('should fail when encrypted private key file cannot be read', async function () { + const requestBody = { + walletId: walletId, + payload: { amount: '1000000' }, + walletPassphrase: walletPassphrase, + }; + + fsReadFileStub = sinon.stub(fs, 'readFile').rejects(new Error('ENOENT: File not found')); + + 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, 500); + result.body.should.have.property('error'); + }); + + it('should fail when walletId is not found in encrypted private keys file', async function () { + const differentWalletId = 'different-ofc-wallet-id-123'; + + const requestBody = { + walletId: differentWalletId, + payload: { amount: '1000000' }, + walletPassphrase: 'test-pass', + }; + + process.env[`WALLET_${differentWalletId}_PASSPHRASE`] = 'test-pass'; + + fsReadFileStub = sinon.stub(fs, 'readFile').resolves( + JSON.stringify({ + [walletId]: encryptedPrivKey, + }) + ); + + 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, 500); + result.body.should.have.property('error'); + + delete process.env[`WALLET_${differentWalletId}_PASSPHRASE`]; + }); + + it('should fail when wallet passphrase environment variable is missing', async function () { + delete process.env[`WALLET_${walletId}_PASSPHRASE`]; + + const requestBody = { + walletId: walletId, + payload: { amount: '1000000' }, + }; + + 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, 500); + assert.ok(result.body); + }); + + it('should fail when private key decryption fails', async function () { + const requestBody = { + walletId: walletId, + payload: { amount: '1000000' }, + walletPassphrase: 'wrong_passphrase', + }; + + fsReadFileStub = sinon.stub(fs, 'readFile').resolves( + JSON.stringify({ + [walletId]: encryptedPrivKey, + }) + ); + + sinon.stub(BitGo.prototype, 'decrypt').throws(new Error('Invalid passphrase')); + + 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, 500); + result.body.should.have.property('error'); + }); + + it('should fail when coin.signMessage throws an error', async function () { + const requestBody = { + walletId: walletId, + payload: { amount: '1000000' }, + walletPassphrase: walletPassphrase, + }; + + fsReadFileStub = sinon.stub(fs, 'readFile').resolves( + JSON.stringify({ + [walletId]: encryptedPrivKey, + }) + ); + + const mockCoin = { + signMessage: sinon.stub().rejects(new Error('Signing failed')), + }; + + sinon.stub(BitGo.prototype, 'coin').returns(mockCoin as any); + sinon.stub(BitGo.prototype, 'decrypt').returns(decryptedPrivKey); + + 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, 500); + result.body.should.have.property('error'); + }); + + it('should reject request with missing walletId', async function () { + const requestBody = { + payload: { amount: '1000000' }, + walletPassphrase: walletPassphrase, + }; + + const result = await agent + .post('/api/v2/ofc/signPayload') + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.ok(result.status >= 400); + }); + + it('should reject request with missing payload', async function () { + const requestBody = { + walletId: walletId, + walletPassphrase: walletPassphrase, + }; + + const result = await agent + .post('/api/v2/ofc/signPayload') + .set('Authorization', 'Bearer test_access_token_12345') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.ok(result.status >= 400); + }); + }); + }); + + describe('PostOfcExtSignPayload route definition', function () { + it('should have the correct path', function () { + assert.strictEqual(PostOfcExtSignPayload.path, '/api/v2/ofc/signPayload'); + }); + + it('should have the correct HTTP method', function () { + assert.strictEqual(PostOfcExtSignPayload.method, 'POST'); + }); + + it('should have the correct response types', function () { + assert.ok(PostOfcExtSignPayload.response[200]); + assert.ok(PostOfcExtSignPayload.response[400]); + }); + }); +});