@@ -10,6 +10,7 @@ import urlLib from 'url';
1010import querystring from 'querystring' ;
1111
1212import { ApiResponseError , BitGoRequest } from '@bitgo/sdk-core' ;
13+ import { extractPathWithQuery } from '@bitgo/sdk-hmac' ;
1314
1415import { AuthVersion , VerifyResponseOptions } from './types' ;
1516import { 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