diff --git a/modules/express/src/args.ts b/modules/express/src/args.ts index a4181343b5..a956aa324e 100644 --- a/modules/express/src/args.ts +++ b/modules/express/src/args.ts @@ -106,6 +106,10 @@ parser.addArgument(['--signerFileSystemPath'], { help: 'Local path specifying where an Express signer machine keeps encrypted user private keys.', }); +parser.addArgument(['--signerAuthToken'], { + help: 'Shared bearer token required to call external-signer routes (also BITGO_SIGNER_AUTH_TOKEN). Required in signerMode and when externalSignerUrl is set.', +}); + parser.addArgument(['--lightningSignerFileSystemPath'], { help: 'Local path specifying where an Express machine keeps lightning signer urls.', }); diff --git a/modules/express/src/clientRoutes.ts b/modules/express/src/clientRoutes.ts index bdcad288ab..c7c1fb80c4 100755 --- a/modules/express/src/clientRoutes.ts +++ b/modules/express/src/clientRoutes.ts @@ -54,6 +54,7 @@ import { RequestTracer } from 'bitgo/dist/src/v2/internal/util'; import { Config } from './config'; import { ApiResponseError, BitGoExpressError } from './errors'; import { promises as fs } from 'fs'; +import * as crypto from 'crypto'; import { retryPromise } from './retryPromise'; import { handleCreateSignerMacaroon, @@ -655,10 +656,12 @@ export async function handleV2OFCSignPayload( if (externalSignerUrl) { const { body: payloadWithSignature } = await retryPromise( () => - superagent - .post(`${externalSignerUrl}/api/v2/ofc/signPayload`) - .type('json') - .send({ walletId: walletId, payload: payload }), + postToExternalSigner( + externalSignerUrl, + '/api/v2/ofc/signPayload', + { walletId: walletId, payload: payload }, + req.config?.signerAuthToken + ), (err, tryCount) => { debug(`failed to connect to external signer (attempt ${tryCount}, error: ${err.message})`); } @@ -1021,7 +1024,7 @@ function createSendParams(req: express.Request) { if (req.config?.externalSignerUrl !== undefined) { return { ...req.body, - customSigningFunction: createCustomSigningFunction(req.config.externalSignerUrl), + customSigningFunction: createCustomSigningFunction(req.config.externalSignerUrl, req.config.signerAuthToken), }; } else { return req.body; @@ -1031,32 +1034,46 @@ function createSendParams(req: express.Request) { function createTSSSendParams(req: express.Request, wallet: Wallet) { if (req.config?.externalSignerUrl !== undefined) { const coin = req.bitgo.coin(req.params.coin); + const externalSignerUrl = req.config.externalSignerUrl; + const signerAuthToken = req.config.signerAuthToken; if (coin.getMPCAlgorithm() === MPCType.EDDSA) { if (wallet._wallet.multisigTypeVersion === 'MPCv2') { return { ...req.body, customEddsaMPCv2SigningRound1GenerationFunction: createCustomEddsaMPCv2SigningRound1Generator( - req.config.externalSignerUrl, - req.params.coin + externalSignerUrl, + req.params.coin, + signerAuthToken ), customEddsaMPCv2SigningRound2GenerationFunction: createCustomEddsaMPCv2SigningRound2Generator( - req.config.externalSignerUrl, - req.params.coin + externalSignerUrl, + req.params.coin, + signerAuthToken ), customEddsaMPCv2SigningRound3GenerationFunction: createCustomEddsaMPCv2SigningRound3Generator( - req.config.externalSignerUrl, - req.params.coin + externalSignerUrl, + req.params.coin, + signerAuthToken ), }; } else { return { ...req.body, customCommitmentGeneratingFunction: createCustomCommitmentGenerator( - req.config.externalSignerUrl, - req.params.coin + externalSignerUrl, + req.params.coin, + signerAuthToken + ), + customRShareGeneratingFunction: createCustomRShareGenerator( + externalSignerUrl, + req.params.coin, + signerAuthToken + ), + customGShareGeneratingFunction: createCustomGShareGenerator( + externalSignerUrl, + req.params.coin, + signerAuthToken ), - customRShareGeneratingFunction: createCustomRShareGenerator(req.config.externalSignerUrl, req.params.coin), - customGShareGeneratingFunction: createCustomGShareGenerator(req.config.externalSignerUrl, req.params.coin), }; } } else if (coin.getMPCAlgorithm() === MPCType.ECDSA) { @@ -1064,31 +1081,44 @@ function createTSSSendParams(req: express.Request, wallet: Wallet) { return { ...req.body, customMPCv2SigningRound1GenerationFunction: createCustomMPCv2SigningRound1Generator( - req.config.externalSignerUrl, - req.params.coin + externalSignerUrl, + req.params.coin, + signerAuthToken ), customMPCv2SigningRound2GenerationFunction: createCustomMPCv2SigningRound2Generator( - req.config.externalSignerUrl, - req.params.coin + externalSignerUrl, + req.params.coin, + signerAuthToken ), customMPCv2SigningRound3GenerationFunction: createCustomMPCv2SigningRound3Generator( - req.config.externalSignerUrl, - req.params.coin + externalSignerUrl, + req.params.coin, + signerAuthToken ), }; } else { return { ...req.body, customPaillierModulusGeneratingFunction: createCustomPaillierModulusGetter( - req.config.externalSignerUrl, - req.params.coin + externalSignerUrl, + req.params.coin, + signerAuthToken + ), + customKShareGeneratingFunction: createCustomKShareGenerator( + externalSignerUrl, + req.params.coin, + signerAuthToken ), - customKShareGeneratingFunction: createCustomKShareGenerator(req.config.externalSignerUrl, req.params.coin), customMuDeltaShareGeneratingFunction: createCustomMuDeltaShareGenerator( - req.config.externalSignerUrl, - req.params.coin + externalSignerUrl, + req.params.coin, + signerAuthToken + ), + customSShareGeneratingFunction: createCustomSShareGenerator( + externalSignerUrl, + req.params.coin, + signerAuthToken ), - customSShareGeneratingFunction: createCustomSShareGenerator(req.config.externalSignerUrl, req.params.coin), }; } } else { @@ -1813,16 +1843,74 @@ export function typedPromiseWrapper(promiseRequestHandler: TypedRequestHandler) }; } -export function createCustomSigningFunction(externalSignerUrl: string): CustomSigningFunction { +function secureCompare(a: string, b: string): boolean { + const bufA = Buffer.from(a); + const bufB = Buffer.from(b); + if (bufA.length !== bufB.length) { + return false; + } + return crypto.timingSafeEqual(bufA, bufB); +} + +/** + * Reject unauthenticated or incorrectly authenticated calls to external-signer routes. + * signerMode hosts hold user private keys; a missing bearer previously allowed anyone + * who could reach the port to obtain arbitrary signatures (CWE-306). + */ +export function assertExternalSignerAuthorized(req: express.Request, config: Config): void { + const expected = config.signerAuthToken; + if (!expected) { + throw new ApiResponseError('External signer authentication is not configured', 500); + } + let provided: string | undefined; + const authorization = req.headers.authorization; + if (authorization) { + const authSplit = authorization.split(' '); + if (authSplit.length === 2 && authSplit[0].toLowerCase() === 'bearer') { + provided = authSplit[1]; + } + } + if (!provided || !secureCompare(provided, expected)) { + throw new ApiResponseError('Unauthorized', 401); + } +} + +function withExternalSignerAuth( + config: Config, + handler: (req: T) => Promise | unknown +) { + return async (req: T) => { + assertExternalSignerAuthorized(req, config); + return handler(req); + }; +} + +function postToExternalSigner(externalSignerUrl: string, routePath: string, body: unknown, signerAuthToken?: string) { + const request = superagent.post(`${externalSignerUrl}${routePath}`).type('json'); + if (signerAuthToken) { + request.set('Authorization', `Bearer ${signerAuthToken}`); + } + return request.send(body); +} + +export function createCustomSigningFunction( + externalSignerUrl: string, + signerAuthToken?: string +): CustomSigningFunction { return async function (params): Promise { const { body: signedTx } = await retryPromise( () => - superagent.post(`${externalSignerUrl}/api/v2/${params.coin.getChain()}/sign`).type('json').send({ - txPrebuild: params.txPrebuild, - pubs: params.pubs, - derivationSeed: params.derivationSeed, - signingStep: params.signingStep, - }), + postToExternalSigner( + externalSignerUrl, + `/api/v2/${params.coin.getChain()}/sign`, + { + txPrebuild: params.txPrebuild, + pubs: params.pubs, + derivationSeed: params.derivationSeed, + signingStep: params.signingStep, + }, + signerAuthToken + ), (err, tryCount) => { debug(`failed to connect to external signer (attempt ${tryCount}, error: ${err.message})`); } @@ -1832,13 +1920,15 @@ export function createCustomSigningFunction(externalSignerUrl: string): CustomSi } export function createCustomPaillierModulusGetter( externalSignerUrl: string, - coin: string + coin: string, + signerAuthToken?: string ): CustomPaillierModulusGetterFunction { return async function (params): Promise<{ userPaillierModulus: string; }> { const { body: result } = await retryPromise( - () => superagent.post(`${externalSignerUrl}/api/v2/${coin}/tssshare/PaillierModulus`).type('json').send(params), + () => + postToExternalSigner(externalSignerUrl, `/api/v2/${coin}/tssshare/PaillierModulus`, params, signerAuthToken), (err, tryCount) => { debug(`failed to connect to external signer (attempt ${tryCount}, error: ${err.message})`); } @@ -1847,10 +1937,14 @@ export function createCustomPaillierModulusGetter( }; } -export function createCustomKShareGenerator(externalSignerUrl: string, coin: string): CustomKShareGeneratingFunction { +export function createCustomKShareGenerator( + externalSignerUrl: string, + coin: string, + signerAuthToken?: string +): CustomKShareGeneratingFunction { return async function (params): Promise { const { body: result } = await retryPromise( - () => superagent.post(`${externalSignerUrl}/api/v2/${coin}/tssshare/K`).type('json').send(params), + () => postToExternalSigner(externalSignerUrl, `/api/v2/${coin}/tssshare/K`, params, signerAuthToken), (err, tryCount) => { debug(`failed to connect to external signer (attempt ${tryCount}, error: ${err.message})`); } @@ -1861,11 +1955,12 @@ export function createCustomKShareGenerator(externalSignerUrl: string, coin: str export function createCustomMuDeltaShareGenerator( externalSignerUrl: string, - coin: string + coin: string, + signerAuthToken?: string ): CustomMuDeltaShareGeneratingFunction { return async function (params): Promise { const { body: result } = await retryPromise( - () => superagent.post(`${externalSignerUrl}/api/v2/${coin}/tssshare/MuDelta`).type('json').send(params), + () => postToExternalSigner(externalSignerUrl, `/api/v2/${coin}/tssshare/MuDelta`, params, signerAuthToken), (err, tryCount) => { debug(`failed to connect to external signer (attempt ${tryCount}, error: ${err.message})`); } @@ -1874,10 +1969,14 @@ export function createCustomMuDeltaShareGenerator( }; } -export function createCustomSShareGenerator(externalSignerUrl: string, coin: string): CustomSShareGeneratingFunction { +export function createCustomSShareGenerator( + externalSignerUrl: string, + coin: string, + signerAuthToken?: string +): CustomSShareGeneratingFunction { return async function (params): Promise { const { body: result } = await retryPromise( - () => superagent.post(`${externalSignerUrl}/api/v2/${coin}/tssshare/S`).type('json').send(params), + () => postToExternalSigner(externalSignerUrl, `/api/v2/${coin}/tssshare/S`, params, signerAuthToken), (err, tryCount) => { debug(`failed to connect to external signer (attempt ${tryCount}, error: ${err.message})`); } @@ -1888,7 +1987,8 @@ export function createCustomSShareGenerator(externalSignerUrl: string, coin: str export function createCustomCommitmentGenerator( externalSignerUrl: string, - coin: string + coin: string, + signerAuthToken?: string ): CustomCommitmentGeneratingFunction { return async function (params): Promise<{ userToBitgoCommitment: CommitmentShareRecord; @@ -1896,7 +1996,7 @@ export function createCustomCommitmentGenerator( encryptedUserToBitgoRShare: EncryptedSignerShareRecord; }> { const { body: result } = await retryPromise( - () => superagent.post(`${externalSignerUrl}/api/v2/${coin}/tssshare/commitment`).type('json').send(params), + () => postToExternalSigner(externalSignerUrl, `/api/v2/${coin}/tssshare/commitment`, params, signerAuthToken), (err, tryCount) => { debug(`failed to connect to external signer (attempt ${tryCount}, error: ${err.message})`); } @@ -1905,10 +2005,14 @@ export function createCustomCommitmentGenerator( }; } -export function createCustomRShareGenerator(externalSignerUrl: string, coin: string): CustomRShareGeneratingFunction { +export function createCustomRShareGenerator( + externalSignerUrl: string, + coin: string, + signerAuthToken?: string +): CustomRShareGeneratingFunction { return async function (params): Promise<{ rShare: SignShare }> { const { body: rShare } = await retryPromise( - () => superagent.post(`${externalSignerUrl}/api/v2/${coin}/tssshare/R`).type('json').send(params), + () => postToExternalSigner(externalSignerUrl, `/api/v2/${coin}/tssshare/R`, params, signerAuthToken), (err, tryCount) => { debug(`failed to connect to external signer (attempt ${tryCount}, error: ${err.message})`); } @@ -1917,10 +2021,14 @@ export function createCustomRShareGenerator(externalSignerUrl: string, coin: str }; } -export function createCustomGShareGenerator(externalSignerUrl: string, coin: string): CustomGShareGeneratingFunction { +export function createCustomGShareGenerator( + externalSignerUrl: string, + coin: string, + signerAuthToken?: string +): CustomGShareGeneratingFunction { return async function (params): Promise { const { body: signedTx } = await retryPromise( - () => superagent.post(`${externalSignerUrl}/api/v2/${coin}/tssshare/G`).type('json').send(params), + () => postToExternalSigner(externalSignerUrl, `/api/v2/${coin}/tssshare/G`, params, signerAuthToken), (err, tryCount) => { debug(`failed to connect to external signer (attempt ${tryCount}, error: ${err.message})`); } @@ -1931,11 +2039,12 @@ export function createCustomGShareGenerator(externalSignerUrl: string, coin: str export function createCustomMPCv2SigningRound1Generator( externalSignerUrl: string, - coin: string + coin: string, + signerAuthToken?: string ): CustomMPCv2SigningRound1GeneratingFunction { return async function (params) { const { body: result } = await retryPromise( - () => superagent.post(`${externalSignerUrl}/api/v2/${coin}/tssshare/MPCv2Round1`).type('json').send(params), + () => postToExternalSigner(externalSignerUrl, `/api/v2/${coin}/tssshare/MPCv2Round1`, params, signerAuthToken), (err, tryCount) => { debug(`failed to connect to external signer (attempt ${tryCount}, error: ${err.message})`); } @@ -1946,11 +2055,12 @@ export function createCustomMPCv2SigningRound1Generator( export function createCustomMPCv2SigningRound2Generator( externalSignerUrl: string, - coin: string + coin: string, + signerAuthToken?: string ): CustomMPCv2SigningRound2GeneratingFunction { return async function (params) { const { body: result } = await retryPromise( - () => superagent.post(`${externalSignerUrl}/api/v2/${coin}/tssshare/MPCv2Round2`).type('json').send(params), + () => postToExternalSigner(externalSignerUrl, `/api/v2/${coin}/tssshare/MPCv2Round2`, params, signerAuthToken), (err, tryCount) => { debug(`failed to connect to external signer (attempt ${tryCount}, error: ${err.message})`); } @@ -1961,11 +2071,12 @@ export function createCustomMPCv2SigningRound2Generator( export function createCustomMPCv2SigningRound3Generator( externalSignerUrl: string, - coin: string + coin: string, + signerAuthToken?: string ): CustomMPCv2SigningRound3GeneratingFunction { return async function (params) { const { body: result } = await retryPromise( - () => superagent.post(`${externalSignerUrl}/api/v2/${coin}/tssshare/MPCv2Round3`).type('json').send(params), + () => postToExternalSigner(externalSignerUrl, `/api/v2/${coin}/tssshare/MPCv2Round3`, params, signerAuthToken), (err, tryCount) => { debug(`failed to connect to external signer (attempt ${tryCount}, error: ${err.message})`); } @@ -1976,11 +2087,13 @@ export function createCustomMPCv2SigningRound3Generator( export function createCustomEddsaMPCv2SigningRound1Generator( externalSignerUrl: string, - coin: string + coin: string, + signerAuthToken?: string ): CustomEddsaMPCv2SigningRound1GeneratingFunction { return async function (params) { const { body: result } = await retryPromise( - () => superagent.post(`${externalSignerUrl}/api/v2/${coin}/tssshare/EddsaMPCv2Round1`).type('json').send(params), + () => + postToExternalSigner(externalSignerUrl, `/api/v2/${coin}/tssshare/EddsaMPCv2Round1`, params, signerAuthToken), (err, tryCount) => { debug(`failed to connect to external signer (attempt ${tryCount}, error: ${err.message})`); } @@ -1991,11 +2104,13 @@ export function createCustomEddsaMPCv2SigningRound1Generator( export function createCustomEddsaMPCv2SigningRound2Generator( externalSignerUrl: string, - coin: string + coin: string, + signerAuthToken?: string ): CustomEddsaMPCv2SigningRound2GeneratingFunction { return async function (params) { const { body: result } = await retryPromise( - () => superagent.post(`${externalSignerUrl}/api/v2/${coin}/tssshare/EddsaMPCv2Round2`).type('json').send(params), + () => + postToExternalSigner(externalSignerUrl, `/api/v2/${coin}/tssshare/EddsaMPCv2Round2`, params, signerAuthToken), (err, tryCount) => { debug(`failed to connect to external signer (attempt ${tryCount}, error: ${err.message})`); } @@ -2006,11 +2121,13 @@ export function createCustomEddsaMPCv2SigningRound2Generator( export function createCustomEddsaMPCv2SigningRound3Generator( externalSignerUrl: string, - coin: string + coin: string, + signerAuthToken?: string ): CustomEddsaMPCv2SigningRound3GeneratingFunction { return async function (params) { const { body: result } = await retryPromise( - () => superagent.post(`${externalSignerUrl}/api/v2/${coin}/tssshare/EddsaMPCv2Round3`).type('json').send(params), + () => + postToExternalSigner(externalSignerUrl, `/api/v2/${coin}/tssshare/EddsaMPCv2Round3`, params, signerAuthToken), (err, tryCount) => { debug(`failed to connect to external signer (attempt ${tryCount}, error: ${err.message})`); } @@ -2224,11 +2341,17 @@ export function setupSigningRoutes(app: express.Application, config: Config): vo const router = createExpressRouter(); app.use(router); - router.post('express.v2.coin.sign', [prepareBitGo(config), typedPromiseWrapper(handleV2Sign)]); - router.post('express.v2.tssshare.generate', [prepareBitGo(config), typedPromiseWrapper(handleV2GenerateShareTSS)]); + router.post('express.v2.coin.sign', [ + prepareBitGo(config), + typedPromiseWrapper(withExternalSignerAuth(config, handleV2Sign)), + ]); + router.post('express.v2.tssshare.generate', [ + prepareBitGo(config), + typedPromiseWrapper(withExternalSignerAuth(config, handleV2GenerateShareTSS)), + ]); router.post('express.v2.ofc.extSignPayload', [ prepareBitGo(config), - typedPromiseWrapper(handleV2OFCSignPayloadInExtSigningMode), + typedPromiseWrapper(withExternalSignerAuth(config, handleV2OFCSignPayloadInExtSigningMode)), ]); } diff --git a/modules/express/src/config.ts b/modules/express/src/config.ts index 05977f2e44..cadf16f983 100644 --- a/modules/express/src/config.ts +++ b/modules/express/src/config.ts @@ -40,6 +40,12 @@ export interface Config { externalSignerUrl?: string; signerMode?: boolean; signerFileSystemPath?: string; + /** + * Shared secret required for all external-signer HTTP routes when signerMode is enabled, + * and sent by generator Express instances when calling externalSignerUrl. + * Configure via --signerAuthToken or BITGO_SIGNER_AUTH_TOKEN. + */ + signerAuthToken?: string; lightningSignerFileSystemPath?: string; keepAliveTimeout?: number; headersTimeout?: number; @@ -66,6 +72,7 @@ export const ArgConfig = (args): Partial => ({ externalSignerUrl: args.externalSignerUrl, signerMode: args.signerMode, signerFileSystemPath: args.signerFileSystemPath, + signerAuthToken: args.signerAuthToken, lightningSignerFileSystemPath: args.lightningSignerFileSystemPath, keepAliveTimeout: args.keepalivetimeout, headersTimeout: args.headerstimeout, @@ -92,6 +99,7 @@ export const EnvConfig = (): Partial => ({ externalSignerUrl: readEnvVar('BITGO_EXTERNAL_SIGNER_URL'), signerMode: readEnvVar('BITGO_SIGNER_MODE') ? true : undefined, signerFileSystemPath: readEnvVar('BITGO_SIGNER_FILE_SYSTEM_PATH'), + signerAuthToken: readEnvVar('BITGO_SIGNER_AUTH_TOKEN'), lightningSignerFileSystemPath: readEnvVar('BITGO_LIGHTNING_SIGNER_FILE_SYSTEM_PATH'), keepAliveTimeout: Number(readEnvVar('BITGO_KEEP_ALIVE_TIMEOUT')), headersTimeout: Number(readEnvVar('BITGO_HEADERS_TIMEOUT')), @@ -178,6 +186,7 @@ function mergeConfigs(...configs: Partial[]): Config { externalSignerUrl, signerMode: get('signerMode'), signerFileSystemPath: get('signerFileSystemPath'), + signerAuthToken: get('signerAuthToken'), lightningSignerFileSystemPath: get('lightningSignerFileSystemPath'), keepAliveTimeout: get('keepAliveTimeout'), headersTimeout: get('headersTimeout'), diff --git a/modules/express/src/expressApp.ts b/modules/express/src/expressApp.ts index ddbe769161..1637c49a06 100644 --- a/modules/express/src/expressApp.ts +++ b/modules/express/src/expressApp.ts @@ -210,6 +210,7 @@ function checkPreconditions(config: Config) { externalSignerUrl, signerMode, signerFileSystemPath, + signerAuthToken, lightningSignerFileSystemPath, } = config; @@ -260,6 +261,14 @@ function checkPreconditions(config: Config) { ); } + // External signing routes hold user private keys. Require a shared secret so unauthenticated + // network clients cannot use the machine as a signing oracle (CWE-306). + if ((signerMode !== undefined || externalSignerUrl !== undefined) && !signerAuthToken) { + throw new ExternalSignerConfigError( + 'signerAuthToken must be set when running in external signing mode or when externalSignerUrl is configured. Set --signerAuthToken or BITGO_SIGNER_AUTH_TOKEN.' + ); + } + if (signerFileSystemPath !== undefined) { checkJsonFilePath(signerFileSystemPath); } diff --git a/modules/express/src/typedRoutes/api/v2/coinSign.ts b/modules/express/src/typedRoutes/api/v2/coinSign.ts index 479c1a4d1a..5b3bfa0a2b 100644 --- a/modules/express/src/typedRoutes/api/v2/coinSign.ts +++ b/modules/express/src/typedRoutes/api/v2/coinSign.ts @@ -152,6 +152,7 @@ export const CoinSignResponse = { * * **Configuration Requirements:** * - `signerFileSystemPath`: Path to JSON file containing encrypted private keys + * - `signerAuthToken` / `BITGO_SIGNER_AUTH_TOKEN`: required bearer token for all signer routes * - Environment variable: `WALLET_{walletId}_PASSPHRASE` for each wallet * * **Request Body:** diff --git a/modules/express/test/integration/externalSigner.ts b/modules/express/test/integration/externalSigner.ts index 3400cf5ed6..af73449e74 100644 --- a/modules/express/test/integration/externalSigner.ts +++ b/modules/express/test/integration/externalSigner.ts @@ -18,6 +18,7 @@ describe('Custom signing function', () => { debug: true, env: 'test', externalSignerUrl, + signerAuthToken: 'test-signer-auth-token', timeout: 60000, }; @@ -42,6 +43,7 @@ describe('Custom signing function', () => { // setup nock to external signer const signernock = nock(externalSignerUrl) .post('/api/v2/btc/sign') + .matchHeader('authorization', 'Bearer test-signer-auth-token') .reply(200, { externalSigner: 'external signer response' }); // setup nock to wallet platform GET /wallet/fakeid diff --git a/modules/express/test/unit/bitgoExpress.ts b/modules/express/test/unit/bitgoExpress.ts index fc9cdbd06b..45a5047734 100644 --- a/modules/express/test/unit/bitgoExpress.ts +++ b/modules/express/test/unit/bitgoExpress.ts @@ -477,6 +477,7 @@ describe('Bitgo Express', function () { env: 'test', signerMode: 'signerMode', signerFileSystemPath: 'signerFileSystemPath', + signerAuthToken: 'test-signer-auth-token', }; app(args); @@ -505,16 +506,35 @@ describe('Bitgo Express', function () { const readFileStub = sinon.stub(fs, 'readFileSync').returns(validPrvJSON); args.signerMode = 'signerMode'; + args.signerAuthToken = 'test-signer-auth-token'; (() => expressApp(args)).should.not.throw(); readFileStub.restore(); }); + it('should require signerAuthToken when running in signer mode', function () { + const readFileStub = sinon.stub(fs, 'readFileSync').returns(validPrvJSON); + const args: any = { + env: 'test', + signerMode: 'signerMode', + signerFileSystemPath: 'signerFileSystemPath', + }; + (() => expressApp(args)).should.throw({ + name: 'ExternalSignerConfigError', + message: + 'signerAuthToken must be set when running in external signing mode or when externalSignerUrl is configured. Set --signerAuthToken or BITGO_SIGNER_AUTH_TOKEN.', + }); + args.signerAuthToken = 'test-signer-auth-token'; + (() => expressApp(args)).should.not.throw(); + readFileStub.restore(); + }); + it('should require that an externalSignerUrl and signerMode are not both set', function () { const args: any = { env: 'test', signerMode: 'signerMode', externalSignerUrl: 'externalSignerUrl', + signerAuthToken: 'test-signer-auth-token', }; (() => expressApp(args)).should.throw({ name: 'ExternalSignerConfigError', @@ -549,6 +569,7 @@ describe('Bitgo Express', function () { env: 'test', signerMode: 'signerMode', signerFileSystemPath: 'invalidSignerFileSystemPath', + signerAuthToken: 'test-signer-auth-token', }; (() => expressApp(args)).should.throw(); diff --git a/modules/express/test/unit/config.ts b/modules/express/test/unit/config.ts index 5eba08d31c..478e00b582 100644 --- a/modules/express/test/unit/config.ts +++ b/modules/express/test/unit/config.ts @@ -125,6 +125,7 @@ describe('Config:', () => { BITGO_EXTERNAL_SIGNER_URL: 'envexternalSignerUrl', BITGO_SIGNER_MODE: 'envsignerMode', BITGO_SIGNER_FILE_SYSTEM_PATH: 'envsignerFileSystemPath', + BITGO_SIGNER_AUTH_TOKEN: 'envsignerAuthToken', BITGO_LIGHTNING_SIGNER_FILE_SYSTEM_PATH: 'envlightningSignerFileSystemPath', BITGO_KEEP_ALIVE_TIMETOUT: 'envkeepalivetimeout', BITGO_HEADERS_TIMETOUT: 'envheaderstimeout', @@ -153,6 +154,7 @@ describe('Config:', () => { enclavedExpressSSLCert: 'argenclavedExpressSSLCert', signerMode: 'argsignerMode', signerFileSystemPath: 'argsignerFileSystemPath', + signerAuthToken: 'argsignerAuthToken', lightningSignerFileSystemPath: 'arglightningSignerFileSystemPath', keepalivetimeout: 'argkeepalivetimeout', headerstimeout: 'argheaderstimeout', @@ -181,6 +183,7 @@ describe('Config:', () => { externalSignerUrl: 'https://argexternalSignerUrl', signerMode: 'argsignerMode', signerFileSystemPath: 'argsignerFileSystemPath', + signerAuthToken: 'argsignerAuthToken', lightningSignerFileSystemPath: 'arglightningSignerFileSystemPath', keepAliveTimeout: 'argkeepalivetimeout', headersTimeout: 'argheaderstimeout', diff --git a/modules/express/test/unit/typedRoutes/coinSign.ts b/modules/express/test/unit/typedRoutes/coinSign.ts index ed87e12adc..26b0e9aa89 100644 --- a/modules/express/test/unit/typedRoutes/coinSign.ts +++ b/modules/express/test/unit/typedRoutes/coinSign.ts @@ -59,6 +59,7 @@ describe('CoinSign codec tests (External Signer Mode)', function () { agent = setupAgent({ signerMode: true, signerFileSystemPath: signerFilePath, + signerAuthToken: 'test_access_token_12345', }); }); @@ -149,6 +150,42 @@ describe('CoinSign codec tests (External Signer Mode)', function () { assert.strictEqual(signTxCall.isLastSignature, true); }); + it('should reject unauthenticated sign requests in external signer mode', async function () { + const requestBody = { + txPrebuild: { + walletId: walletId, + txHex: + '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + }, + isLastSignature: true, + }; + + const result = await agent.post(`/api/v2/${coin}/sign`).set('Content-Type', 'application/json').send(requestBody); + + assert.strictEqual(result.status, 401); + result.body.should.have.property('message', 'Unauthorized'); + }); + + it('should reject sign requests with an incorrect signer auth token', async function () { + const requestBody = { + txPrebuild: { + walletId: walletId, + txHex: + '0100000001c7dad3d9607a23c45a6c1c5ad7bce02acff71a0f21eb4a72a59d0c0e19402d0f0000000000ffffffff0180a21900000000001976a914c918e1b36f2c72b1aaef94dbb7f578a4b68b542788ac00000000', + }, + isLastSignature: true, + }; + + const result = await agent + .post(`/api/v2/${coin}/sign`) + .set('Authorization', 'Bearer wrong-token') + .set('Content-Type', 'application/json') + .send(requestBody); + + assert.strictEqual(result.status, 401); + result.body.should.have.property('message', 'Unauthorized'); + }); + it('should successfully sign with derivationSeed', async function () { const derivationSeed = 'test-derivation-seed-123'; const derivedKey = diff --git a/modules/express/test/unit/typedRoutes/generateShareTSS.ts b/modules/express/test/unit/typedRoutes/generateShareTSS.ts index 2fcf73c278..a0b9ed5ac1 100644 --- a/modules/express/test/unit/typedRoutes/generateShareTSS.ts +++ b/modules/express/test/unit/typedRoutes/generateShareTSS.ts @@ -66,6 +66,7 @@ describe('GenerateShareTSS codec tests (External Signer Mode)', function () { agent = setupAgent({ signerMode: true, signerFileSystemPath: signerFilePath, + signerAuthToken: 'test_access_token_12345', }); }); diff --git a/modules/express/test/unit/typedRoutes/ofcExtSignPayload.ts b/modules/express/test/unit/typedRoutes/ofcExtSignPayload.ts index 1538d52968..016f68e9f8 100644 --- a/modules/express/test/unit/typedRoutes/ofcExtSignPayload.ts +++ b/modules/express/test/unit/typedRoutes/ofcExtSignPayload.ts @@ -47,6 +47,7 @@ describe('OfcExtSignPayload External Signer Mode Tests', function () { agent = setupAgent({ signerMode: true, signerFileSystemPath: signerFilePath, + signerAuthToken: 'test_access_token_12345', }); });