Skip to content

Commit edccb76

Browse files
feat(sdk-hmac): add v4 canonical preimage construction
TICKET: CAAS-660
1 parent ec03d8e commit edccb76

6 files changed

Lines changed: 1058 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 { hmacSha256 } 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 hmacSha256(key, message);
2122
}
2223

2324
/**

modules/sdk-hmac/src/hmacv4.ts

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

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)