Skip to content

Commit fc1ccba

Browse files
feat(sdk-api): add verification flow for v4
TICKET: CAAS-819
1 parent 81d8ac4 commit fc1ccba

File tree

8 files changed

+1395
-62
lines changed

8 files changed

+1395
-62
lines changed

modules/express/src/clientRoutes.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1323,7 +1323,7 @@ function parseBody(req: express.Request, res: express.Response, next: express.Ne
13231323
* @param config
13241324
*/
13251325
function prepareBitGo(config: Config) {
1326-
const { env, customRootUri, customBitcoinNetwork } = config;
1326+
const { env, customRootUri, customBitcoinNetwork, authVersion } = config;
13271327

13281328
return function prepBitGo(req: express.Request, res: express.Response, next: express.NextFunction) {
13291329
// Get access token
@@ -1343,6 +1343,7 @@ function prepareBitGo(config: Config) {
13431343
env,
13441344
customRootURI: customRootUri,
13451345
customBitcoinNetwork,
1346+
authVersion,
13461347
accessToken,
13471348
userAgent,
13481349
...(useProxyUrl

modules/express/src/config.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,19 @@ import 'dotenv/config';
44

55
import { args } from './args';
66

7+
// Falls back to auth version 2 for unrecognized values, preserving existing behavior
8+
// where invalid authVersion values silently defaulted to v2.
9+
// Returns undefined when no value is provided, so mergeConfigs can fall through to other sources.
10+
function parseAuthVersion(val: number | undefined): 2 | 3 | 4 | undefined {
11+
if (val === undefined || isNaN(val)) {
12+
return undefined;
13+
}
14+
if (val === 2 || val === 3 || val === 4) {
15+
return val;
16+
}
17+
return 2;
18+
}
19+
720
function readEnvVar(name, ...deprecatedAliases): string | undefined {
821
if (process.env[name] !== undefined && process.env[name] !== '') {
922
return process.env[name];
@@ -36,7 +49,7 @@ export interface Config {
3649
timeout: number;
3750
customRootUri?: string;
3851
customBitcoinNetwork?: V1Network;
39-
authVersion: number;
52+
authVersion: 2 | 3 | 4;
4053
externalSignerUrl?: string;
4154
signerMode?: boolean;
4255
signerFileSystemPath?: string;
@@ -62,7 +75,7 @@ export const ArgConfig = (args): Partial<Config> => ({
6275
timeout: args.timeout,
6376
customRootUri: args.customrooturi,
6477
customBitcoinNetwork: args.custombitcoinnetwork,
65-
authVersion: args.authVersion,
78+
authVersion: parseAuthVersion(args.authVersion),
6679
externalSignerUrl: args.externalSignerUrl,
6780
signerMode: args.signerMode,
6881
signerFileSystemPath: args.signerFileSystemPath,
@@ -88,7 +101,7 @@ export const EnvConfig = (): Partial<Config> => ({
88101
timeout: Number(readEnvVar('BITGO_TIMEOUT')),
89102
customRootUri: readEnvVar('BITGO_CUSTOM_ROOT_URI'),
90103
customBitcoinNetwork: readEnvVar('BITGO_CUSTOM_BITCOIN_NETWORK') as V1Network,
91-
authVersion: Number(readEnvVar('BITGO_AUTH_VERSION')),
104+
authVersion: parseAuthVersion(Number(readEnvVar('BITGO_AUTH_VERSION'))),
92105
externalSignerUrl: readEnvVar('BITGO_EXTERNAL_SIGNER_URL'),
93106
signerMode: readEnvVar('BITGO_SIGNER_MODE') ? true : undefined,
94107
signerFileSystemPath: readEnvVar('BITGO_SIGNER_FILE_SYSTEM_PATH'),

modules/sdk-api/src/api.ts

Lines changed: 140 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import urlLib from 'url';
1010
import querystring from 'querystring';
1111

1212
import { ApiResponseError, BitGoRequest } from '@bitgo/sdk-core';
13+
import { extractPathWithQuery } from '@bitgo/sdk-hmac';
1314

1415
import { AuthVersion, VerifyResponseOptions } from './types';
1516
import { BitGoAPI } from './bitgoAPI';
@@ -213,63 +214,171 @@ export function setRequestQueryString(req: superagent.SuperAgentRequest): void {
213214
}
214215
}
215216

217+
/** Result from version-specific response verification. */
218+
interface ResponseVerificationResult {
219+
verificationResult: {
220+
isValid: boolean;
221+
expectedHmac: string;
222+
isInResponseValidityWindow: boolean;
223+
verificationTime: number;
224+
};
225+
hmacErrorDetails: Record<string, unknown>;
226+
responseTimestamp: string | number;
227+
}
228+
216229
/**
217-
* Verify that the response received from the server is signed correctly.
218-
* Right now, it is very permissive with the timestamp variance.
230+
* Verify a v4 server response HMAC.
231+
*
232+
* Returns undefined if the server didn't sign the response (e.g. error before
233+
* auth middleware ran), signalling the caller to pass the response through
234+
* without verification.
235+
*
236+
* @param authToken - The raw access token (HMAC key). Guaranteed non-undefined by the caller.
219237
*/
220-
export function verifyResponse(
238+
function verifyV4ResponseHeaders(
221239
bitgo: BitGoAPI,
222-
token: string | undefined,
223240
method: VerifyResponseOptions['method'],
224241
req: superagent.SuperAgentRequest,
225242
response: superagent.Response,
226-
authVersion: AuthVersion
227-
): superagent.Response {
228-
// we can't verify the response if we're not authenticated
229-
if (!req.isV2Authenticated || !req.authenticationToken) {
230-
return response;
243+
authToken: string
244+
): ResponseVerificationResult | undefined {
245+
const hmac = response.header['x-signature'];
246+
const timestamp = response.header['x-request-timestamp'];
247+
const authRequestId = response.header['x-auth-request-id'];
248+
249+
if (!hmac || !timestamp) {
250+
// Server didn't sign the response. This can happen legitimately when the
251+
// request fails before reaching the auth middleware (e.g. 401/404).
252+
debug(
253+
'v4 response verification skipped: server response (status %d) missing HMAC headers (x-signature: %s, x-request-timestamp: %s)',
254+
response.status,
255+
hmac ? 'present' : 'missing',
256+
timestamp ? 'present' : 'missing'
257+
);
258+
return undefined;
259+
}
260+
261+
// Hash the raw response body bytes.
262+
// Convert response.text to a Buffer (UTF-8) so we're hashing the actual bytes,
263+
// not relying on Node's implicit string encoding in crypto.update().
264+
const rawResponseBuffer = Buffer.from(response.text || '');
265+
const bodyHashHex = bitgo.calculateBodyHash(rawResponseBuffer);
266+
267+
// req.v4PathWithQuery is always set by requestPatch; fallback parses req.url as a safety net.
268+
let pathWithQuery = req.v4PathWithQuery;
269+
if (!pathWithQuery) {
270+
// Use sdk-hmac's extractPathWithQuery helper which handles both absolute URLs
271+
pathWithQuery = extractPathWithQuery(req.url);
231272
}
232273

233-
const verificationResponse = bitgo.verifyResponse({
274+
const result = bitgo.verifyResponse({
275+
hmac,
276+
timestampSec: Number(timestamp),
277+
method: req.v4Method || method,
278+
pathWithQuery,
279+
bodyHashHex,
280+
authRequestId: authRequestId || req.v4AuthRequestId || '',
281+
statusCode: response.status,
282+
rawToken: authToken,
283+
});
284+
285+
return {
286+
verificationResult: result,
287+
responseTimestamp: timestamp,
288+
hmacErrorDetails: { expectedHmac: result.expectedHmac, receivedHmac: hmac, preimage: result.preimage },
289+
};
290+
}
291+
292+
/**
293+
* Verify a v2/v3 server response HMAC.
294+
*
295+
* @param authToken - The raw access token (HMAC key). Guaranteed non-undefined by the caller.
296+
*/
297+
function verifyV2V3ResponseHeaders(
298+
bitgo: BitGoAPI,
299+
token: string | undefined,
300+
method: VerifyResponseOptions['method'],
301+
req: superagent.SuperAgentRequest,
302+
response: superagent.Response,
303+
authVersion: AuthVersion,
304+
authToken: string
305+
): ResponseVerificationResult {
306+
const result = bitgo.verifyResponse({
234307
url: req.url,
235308
hmac: response.header.hmac,
236309
statusCode: response.status,
237310
text: response.text,
238311
timestamp: response.header.timestamp,
239-
token: req.authenticationToken,
312+
token: authToken,
240313
method,
241314
authVersion,
242315
});
243316

244-
if (!verificationResponse.isValid) {
245-
// calculate the HMAC
246-
const receivedHmac = response.header.hmac;
247-
const expectedHmac = verificationResponse.expectedHmac;
248-
const signatureSubject = verificationResponse.signatureSubject;
249-
// Log only the first 10 characters of the token to ensure the full token isn't logged.
250-
const partialBitgoToken = token ? token.substring(0, 10) : '';
251-
const errorDetails = {
252-
expectedHmac,
253-
receivedHmac,
254-
hmacInput: signatureSubject,
255-
requestToken: req.authenticationToken,
317+
const partialBitgoToken = token ? token.substring(0, 10) : '';
318+
return {
319+
verificationResult: result,
320+
responseTimestamp: response.header.timestamp,
321+
hmacErrorDetails: {
322+
expectedHmac: result.expectedHmac,
323+
receivedHmac: response.header.hmac,
324+
hmacInput: result.signatureSubject,
325+
requestToken: authToken,
256326
bitgoToken: partialBitgoToken,
257-
};
258-
debug('Invalid response HMAC: %O', errorDetails);
259-
throw new ApiResponseError('invalid response HMAC, possible man-in-the-middle-attack', 511, errorDetails);
327+
},
328+
};
329+
}
330+
331+
/**
332+
* Verify that the response received from the server is signed correctly.
333+
* Right now, it is very permissive with the timestamp variance.
334+
*/
335+
export function verifyResponse(
336+
bitgo: BitGoAPI,
337+
token: string | undefined,
338+
method: VerifyResponseOptions['method'],
339+
req: superagent.SuperAgentRequest,
340+
response: superagent.Response,
341+
authVersion: AuthVersion
342+
): superagent.Response {
343+
// we can't verify the response if we're not authenticated
344+
if (!req.isV2Authenticated || !req.authenticationToken) {
345+
return response;
260346
}
261347

262-
if (bitgo.getAuthVersion() === 3 && !verificationResponse.isInResponseValidityWindow) {
263-
const errorDetails = {
264-
timestamp: response.header.timestamp,
265-
verificationTime: verificationResponse.verificationTime,
266-
};
348+
// --- Build version-specific params, call bitgo.verifyResponse(), collect error context ---
349+
// req.authenticationToken is guaranteed non-undefined here (checked above).
350+
const authToken = req.authenticationToken as string;
351+
let result: ResponseVerificationResult;
352+
353+
if (authVersion === 4) {
354+
const v4Result = verifyV4ResponseHeaders(bitgo, method, req, response, authToken);
355+
if (!v4Result) {
356+
// Server didn't sign the response — pass through without verification
357+
return response;
358+
}
359+
result = v4Result;
360+
} else {
361+
result = verifyV2V3ResponseHeaders(bitgo, token, method, req, response, authVersion, authToken);
362+
}
363+
364+
// --- Common validation for all auth versions ---
365+
const { verificationResult, hmacErrorDetails, responseTimestamp } = result;
366+
367+
if (!verificationResult.isValid) {
368+
debug('Invalid response HMAC: %O', hmacErrorDetails);
369+
throw new ApiResponseError('invalid response HMAC, possible man-in-the-middle-attack', 511, hmacErrorDetails);
370+
}
371+
372+
// v3 and v4 enforce the response validity window; v2 does not
373+
if (authVersion >= 3 && !verificationResult.isInResponseValidityWindow) {
374+
const errorDetails = { timestamp: responseTimestamp, verificationTime: verificationResult.verificationTime };
267375
debug('Server response outside response validity time window: %O', errorDetails);
268376
throw new ApiResponseError(
269377
'server response outside response validity time window, possible man-in-the-middle-attack',
270378
511,
271379
errorDetails
272380
);
273381
}
382+
274383
return response;
275384
}

0 commit comments

Comments
 (0)