Skip to content

Commit 909aaab

Browse files
Merge pull request #8079 from BitGo/CAAS-660-canonicalization-helper
feat(sdk-hmac): add v4 canonical preimage construction
2 parents 839372c + 749faad commit 909aaab

6 files changed

Lines changed: 1144 additions & 2 deletions

File tree

modules/sdk-hmac/src/hmac.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type BinaryLike, createHmac, type KeyObject } from 'crypto';
1+
import { type BinaryLike, type KeyObject } from 'crypto';
22
import * as urlLib from 'url';
33
import * as sjcl from '@bitgo/sjcl';
44
import {
@@ -9,6 +9,7 @@ import {
99
VerifyResponseInfo,
1010
VerifyResponseOptions,
1111
} from './types';
12+
import { createHmacWithSha256 } from './util';
1213

1314
/**
1415
* Calculate the HMAC for the given key and message
@@ -17,7 +18,7 @@ import {
1718
* @returns {*} - the result of the HMAC operation
1819
*/
1920
export function calculateHMAC(key: string | BinaryLike | KeyObject, message: string | BinaryLike): string {
20-
return createHmac('sha256', key).update(message).digest('hex');
21+
return createHmacWithSha256(key, message);
2122
}
2223

2324
/**

modules/sdk-hmac/src/hmacv4.ts

Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
/**
2+
* @prettier
3+
*
4+
* V4 HMAC Authentication Module
5+
*
6+
* This module implements the v4 authentication scheme which uses a canonical
7+
* preimage construction with newline-separated fields and body hashing.
8+
*
9+
* Key differences from v2/v3:
10+
* - Separator: newline (\n) instead of pipe (|)
11+
* - Body: SHA256 hash of raw bytes instead of actual body content
12+
* - Timestamp: seconds instead of milliseconds
13+
* - New field: authRequestId for request tracking
14+
* - Trailing newline in preimage
15+
* - Support for x-original-* headers (proxy scenarios)
16+
*/
17+
18+
import { timingSafeEqual } from 'crypto';
19+
import {
20+
createHmacWithSha256,
21+
sha256Hex,
22+
normalizeMethod,
23+
getTimestampSec,
24+
extractPathWithQuery,
25+
type HashableData,
26+
} from './util';
27+
import {
28+
CalculateV4PreimageOptions,
29+
CalculateV4RequestHmacOptions,
30+
CalculateV4RequestHeadersOptions,
31+
V4RequestHeaders,
32+
VerifyV4ResponseOptions,
33+
VerifyV4ResponseInfo,
34+
} from './types';
35+
36+
/**
37+
* Build canonical preimage for v4 authentication.
38+
*
39+
* The preimage is constructed as newline-separated values with a trailing newline:
40+
* ```
41+
* {timestampSec}
42+
* {METHOD}
43+
* {pathWithQuery}
44+
* {bodyHashHex}
45+
* {authRequestId}
46+
* ```
47+
*
48+
* This function normalizes the HTTP method to uppercase and handles the
49+
* legacy 'del' method conversion to 'DELETE'.
50+
*
51+
* @param options - The preimage components
52+
* @returns Newline-separated canonical preimage string with trailing newline
53+
*
54+
* @example
55+
* ```typescript
56+
* const preimage = calculateV4Preimage({
57+
* timestampSec: 1761100000,
58+
* method: 'post',
59+
* pathWithQuery: '/v2/wallets/transfer?foo=bar',
60+
* bodyHashHex: '0d5e3b7a8f9c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e',
61+
* authRequestId: '1b7a1d2b-7a2f-4e4b-a1f8-c2a5a0f84e3e',
62+
* });
63+
*
64+
* // Result:
65+
* // "1761100000\nPOST\n/v2/wallets/transfer?foo=bar\n0d5e3b...d6e\n1b7a1d2b-7a2f-4e4b-a1f8-c2a5a0f84e3e\n"
66+
* ```
67+
*/
68+
export function calculateV4Preimage({
69+
timestampSec,
70+
method,
71+
pathWithQuery,
72+
bodyHashHex,
73+
authRequestId,
74+
}: CalculateV4PreimageOptions): string {
75+
const normalizedMethod = normalizeMethod(method);
76+
77+
// Build newline-separated preimage with trailing newline
78+
const components = [timestampSec.toString(), normalizedMethod, pathWithQuery, bodyHashHex, authRequestId];
79+
80+
return components.join('\n') + '\n';
81+
}
82+
83+
/**
84+
* Calculate SHA256 hash of body and return as lowercase hex string.
85+
*
86+
* This is used to compute the bodyHashHex field for v4 authentication.
87+
* The hash is computed over the raw bytes of the request body, ensuring
88+
* that the exact bytes sent over the wire are used for signature calculation.
89+
*
90+
* Accepts common byte representations for Node.js and browser environments,
91+
* including Uint8Array and ArrayBuffer for Fetch API compatibility.
92+
*
93+
* @param body - Raw request body (string, Buffer, Uint8Array, or ArrayBuffer)
94+
* @returns Lowercase hex SHA256 hash (64 characters)
95+
*
96+
* @example
97+
* ```typescript
98+
* // Node.js with Buffer
99+
* const hash1 = calculateBodyHash(Buffer.from('{"address":"tb1q...","amount":100000}'));
100+
*
101+
* // Browser with Uint8Array
102+
* const hash2 = calculateBodyHash(new TextEncoder().encode('{"address":"tb1q..."}'));
103+
*
104+
* // Browser with ArrayBuffer
105+
* const hash3 = calculateBodyHash(await response.arrayBuffer());
106+
*
107+
* // All return: '0d5e3b7a8f...' (64 character hex string)
108+
* ```
109+
*/
110+
export function calculateBodyHash(body: HashableData): string {
111+
return sha256Hex(body);
112+
}
113+
114+
/**
115+
* Calculate the HMAC-SHA256 signature for a v4 HTTP request.
116+
*
117+
* This function:
118+
* 1. Builds the canonical preimage from the provided options
119+
* 2. Computes HMAC-SHA256 of the preimage using the raw access token
120+
*
121+
* @param options - Request parameters and raw access token
122+
* @returns Lowercase hex HMAC-SHA256 signature
123+
*
124+
* @example
125+
* ```typescript
126+
* const hmac = calculateV4RequestHmac({
127+
* timestampSec: 1761100000,
128+
* method: 'POST',
129+
* pathWithQuery: '/v2/wallets/transfer',
130+
* bodyHashHex: '0d5e3b...',
131+
* authRequestId: '1b7a1d2b-7a2f-4e4b-a1f8-c2a5a0f84e3e',
132+
* rawToken: 'your-token',
133+
* });
134+
* ```
135+
*/
136+
export function calculateV4RequestHmac({
137+
timestampSec,
138+
method,
139+
pathWithQuery,
140+
bodyHashHex,
141+
authRequestId,
142+
rawToken,
143+
}: CalculateV4RequestHmacOptions): string {
144+
const preimage = calculateV4Preimage({
145+
timestampSec,
146+
method,
147+
pathWithQuery,
148+
bodyHashHex,
149+
authRequestId,
150+
});
151+
152+
return createHmacWithSha256(rawToken, preimage);
153+
}
154+
155+
/**
156+
* Generate all headers required for v4 authenticated requests.
157+
*
158+
* This is a convenience function that:
159+
* 1. Generates the current timestamp (in seconds)
160+
* 2. Calculates the body hash from raw bytes
161+
* 3. Computes the HMAC signature
162+
* 4. Returns all values needed for request headers
163+
*
164+
* @param options - Request parameters including raw body and raw token
165+
* @returns Object containing all v4 authentication header values
166+
*
167+
* @example
168+
* ```typescript
169+
* const headers = calculateV4RequestHeaders({
170+
* method: 'POST',
171+
* pathWithQuery: '/v2/wallets/transfer?foo=bar',
172+
* rawBody: Buffer.from('{"address":"tb1q..."}'),
173+
* rawToken: 'your-token',
174+
* authRequestId: '1b7a1d2b-7a2f-4e4b-a1f8-c2a5a0f84e3e',
175+
* });
176+
*
177+
* // Use headers to set:
178+
* // - Auth-Timestamp: headers.timestampSec
179+
* // - HMAC: headers.hmac
180+
* // - X-Body-Hash: headers.bodyHashHex
181+
* // - X-Request-Id: headers.authRequestId
182+
* ```
183+
*/
184+
export function calculateV4RequestHeaders({
185+
method,
186+
pathWithQuery,
187+
rawBody,
188+
rawToken,
189+
authRequestId,
190+
}: CalculateV4RequestHeadersOptions): V4RequestHeaders {
191+
const timestampSec = getTimestampSec();
192+
const bodyHashHex = calculateBodyHash(rawBody);
193+
194+
const hmac = calculateV4RequestHmac({
195+
timestampSec,
196+
method,
197+
pathWithQuery,
198+
bodyHashHex,
199+
authRequestId,
200+
rawToken,
201+
});
202+
203+
return {
204+
hmac,
205+
timestampSec,
206+
bodyHashHex,
207+
authRequestId,
208+
};
209+
}
210+
211+
/**
212+
* Build canonical preimage for v4 response verification.
213+
*
214+
* Response preimage includes the status code and uses the same format:
215+
* ```
216+
* {timestampSec}
217+
* {METHOD}
218+
* {pathWithQuery}
219+
* {statusCode}
220+
* {bodyHashHex}
221+
* {authRequestId}
222+
* ```
223+
*
224+
* @param options - Response verification parameters
225+
* @returns Newline-separated canonical preimage string with trailing newline
226+
*/
227+
export function calculateV4ResponsePreimage({
228+
timestampSec,
229+
method,
230+
pathWithQuery,
231+
statusCode,
232+
bodyHashHex,
233+
authRequestId,
234+
}: Omit<VerifyV4ResponseOptions, 'hmac' | 'rawToken'>): string {
235+
const normalizedMethod = normalizeMethod(method);
236+
237+
const components = [
238+
timestampSec.toString(),
239+
normalizedMethod,
240+
pathWithQuery,
241+
statusCode.toString(),
242+
bodyHashHex,
243+
authRequestId,
244+
];
245+
246+
return components.join('\n') + '\n';
247+
}
248+
249+
/**
250+
* Verify the HMAC signature of a v4 HTTP response.
251+
*
252+
* This function:
253+
* 1. Reconstructs the canonical preimage from response data
254+
* 2. Calculates the expected HMAC
255+
* 3. Compares with the received HMAC
256+
* 4. Checks if the timestamp is within the validity window
257+
*
258+
* The validity window is:
259+
* - 5 minutes backwards (to account for clock skew and network latency)
260+
* - 1 minute forwards (to account for minor clock differences)
261+
*
262+
* @param options - Response data and raw token for verification
263+
* @returns Verification result including validity and diagnostic info
264+
*/
265+
export function verifyV4Response({
266+
hmac,
267+
timestampSec,
268+
method,
269+
pathWithQuery,
270+
bodyHashHex,
271+
authRequestId,
272+
statusCode,
273+
rawToken,
274+
}: VerifyV4ResponseOptions): VerifyV4ResponseInfo {
275+
// Build the response preimage
276+
const preimage = calculateV4ResponsePreimage({
277+
timestampSec,
278+
method,
279+
pathWithQuery,
280+
statusCode,
281+
bodyHashHex,
282+
authRequestId,
283+
});
284+
285+
// Calculate expected HMAC
286+
const expectedHmac = createHmacWithSha256(rawToken, preimage);
287+
288+
// Use constant-time comparison to prevent timing side-channel attacks
289+
const hmacBuffer = Buffer.from(hmac, 'hex');
290+
const expectedHmacBuffer = Buffer.from(expectedHmac, 'hex');
291+
const isHmacValid =
292+
hmacBuffer.length === expectedHmacBuffer.length && timingSafeEqual(hmacBuffer, expectedHmacBuffer);
293+
294+
// Check timestamp validity window
295+
const nowSec = getTimestampSec();
296+
const backwardValidityWindowSec = 5 * 60; // 5 minutes
297+
const forwardValidityWindowSec = 1 * 60; // 1 minute
298+
const isInResponseValidityWindow =
299+
timestampSec >= nowSec - backwardValidityWindowSec && timestampSec <= nowSec + forwardValidityWindowSec;
300+
301+
return {
302+
isValid: isHmacValid,
303+
expectedHmac,
304+
preimage,
305+
isInResponseValidityWindow,
306+
verificationTime: Date.now(),
307+
};
308+
}
309+
310+
/**
311+
* Extract path with query from x-original-uri header or request URL.
312+
* Always canonicalizes to pathname + search to handle absolute URLs.
313+
*
314+
* @param xOriginalUri - Value of x-original-uri header (if present)
315+
* @param requestUrl - The actual request URL
316+
* @returns The canonical path with query to use for preimage calculation
317+
*
318+
*/
319+
export function getPathWithQuery(xOriginalUri: string | undefined, requestUrl: string): string {
320+
// Prefer x-original-uri if available (proxy scenario)
321+
const rawPath = xOriginalUri ?? requestUrl;
322+
return extractPathWithQuery(rawPath);
323+
}
324+
325+
/**
326+
* Get method from x-original-method header or actual request method.
327+
*
328+
* @param xOriginalMethod - Value of x-original-method header (if present)
329+
* @param requestMethod - The actual request method
330+
* @returns The method to use for preimage calculation
331+
*/
332+
export function getMethod(xOriginalMethod: string | undefined, requestMethod: string): string {
333+
// Prefer x-original-method if available (proxy scenario)
334+
return xOriginalMethod ?? requestMethod;
335+
}

modules/sdk-hmac/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
export * from './hmac';
2+
export * from './hmacv4';
3+
export * from './util';
24
export * from './types';

0 commit comments

Comments
 (0)