Skip to content

Commit f10c69a

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

6 files changed

Lines changed: 1048 additions & 3 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: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
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 } 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+
* @param body - Raw request body (Buffer or string)
84+
* @returns Lowercase hex SHA256 hash (64 characters)
85+
*
86+
* @example
87+
* ```typescript
88+
* const hash = calculateBodyHash(Buffer.from('{"address":"tb1q...","amount":100000}'));
89+
* // Returns: '0d5e3b7a8f...' (64 character hex string)
90+
* ```
91+
*/
92+
export function calculateBodyHash(body: Buffer | string): string {
93+
return sha256Hex(body);
94+
}
95+
96+
/**
97+
* Calculate the HMAC-SHA256 signature for a v4 HTTP request.
98+
*
99+
* This function:
100+
* 1. Builds the canonical preimage from the provided options
101+
* 2. Computes HMAC-SHA256 of the preimage using the raw access token
102+
*
103+
* @param options - Request parameters and raw access token
104+
* @returns Lowercase hex HMAC-SHA256 signature
105+
*
106+
* @example
107+
* ```typescript
108+
* const hmac = calculateV4RequestHmac({
109+
* timestampSec: 1761100000,
110+
* method: 'POST',
111+
* pathWithQuery: '/v2/wallets/transfer',
112+
* bodyHashHex: '0d5e3b...',
113+
* authRequestId: '1b7a1d2b-7a2f-4e4b-a1f8-c2a5a0f84e3e',
114+
* rawToken: 'your-raw-token',
115+
* });
116+
* ```
117+
*/
118+
export function calculateV4RequestHmac({
119+
timestampSec,
120+
method,
121+
pathWithQuery,
122+
bodyHashHex,
123+
authRequestId,
124+
rawToken,
125+
}: CalculateV4RequestHmacOptions): string {
126+
const preimage = calculateV4Preimage({
127+
timestampSec,
128+
method,
129+
pathWithQuery,
130+
bodyHashHex,
131+
authRequestId,
132+
});
133+
134+
return hmacSha256(rawToken, preimage);
135+
}
136+
137+
/**
138+
* Generate all headers required for v4 authenticated requests.
139+
*
140+
* This is a convenience function that:
141+
* 1. Generates the current timestamp (in seconds)
142+
* 2. Calculates the body hash from raw bytes
143+
* 3. Computes the HMAC signature
144+
* 4. Returns all values needed for request headers
145+
*
146+
* @param options - Request parameters including raw body and signing key
147+
* @returns Object containing all v4 authentication header values
148+
*
149+
* @example
150+
* ```typescript
151+
* const headers = calculateV4RequestHeaders({
152+
* method: 'POST',
153+
* pathWithQuery: '/v2/wallets/transfer?foo=bar',
154+
* rawBody: Buffer.from('{"address":"tb1q..."}'),
155+
* signingKey: 'your-signing-key',
156+
* authRequestId: '1b7a1d2b-7a2f-4e4b-a1f8-c2a5a0f84e3e',
157+
* });
158+
*
159+
* // Use headers to set:
160+
* // - Auth-Timestamp: headers.timestampSec
161+
* // - HMAC: headers.hmac
162+
* // - X-Body-Hash: headers.bodyHashHex
163+
* // - X-Request-Id: headers.authRequestId
164+
* ```
165+
*/
166+
export function calculateV4RequestHeaders({
167+
method,
168+
pathWithQuery,
169+
rawBody,
170+
rawToken,
171+
authRequestId,
172+
}: CalculateV4RequestHeadersOptions): V4RequestHeaders {
173+
const timestampSec = getTimestampSec();
174+
const bodyHashHex = calculateBodyHash(rawBody);
175+
176+
const hmac = calculateV4RequestHmac({
177+
timestampSec,
178+
method,
179+
pathWithQuery,
180+
bodyHashHex,
181+
authRequestId,
182+
rawToken,
183+
});
184+
185+
return {
186+
hmac,
187+
timestampSec,
188+
bodyHashHex,
189+
authRequestId,
190+
};
191+
}
192+
193+
/**
194+
* Build canonical preimage for v4 response verification.
195+
*
196+
* Response preimage includes the status code and uses the same format:
197+
* ```
198+
* {timestampSec}
199+
* {METHOD}
200+
* {pathWithQuery}
201+
* {statusCode}
202+
* {bodyHashHex}
203+
* {authRequestId}
204+
* ```
205+
*
206+
* @param options - Response verification parameters
207+
* @returns Newline-separated canonical preimage string with trailing newline
208+
*/
209+
export function calculateV4ResponsePreimage({
210+
timestampSec,
211+
method,
212+
pathWithQuery,
213+
statusCode,
214+
bodyHashHex,
215+
authRequestId,
216+
}: Omit<VerifyV4ResponseOptions, 'hmac' | 'rawToken'>): string {
217+
const normalizedMethod = normalizeMethod(method);
218+
219+
const components = [
220+
timestampSec.toString(),
221+
normalizedMethod,
222+
pathWithQuery,
223+
statusCode.toString(),
224+
bodyHashHex,
225+
authRequestId,
226+
];
227+
228+
return components.join('\n') + '\n';
229+
}
230+
231+
/**
232+
* Verify the HMAC signature of a v4 HTTP response.
233+
*
234+
* This function:
235+
* 1. Reconstructs the canonical preimage from response data
236+
* 2. Calculates the expected HMAC
237+
* 3. Compares with the received HMAC
238+
* 4. Checks if the timestamp is within the validity window
239+
*
240+
* The validity window is:
241+
* - 5 minutes backwards (to account for clock skew and network latency)
242+
* - 1 minute forwards (to account for minor clock differences)
243+
*
244+
* @param options - Response data and signing key for verification
245+
* @returns Verification result including validity and diagnostic info
246+
*/
247+
export function verifyV4Response({
248+
hmac,
249+
timestampSec,
250+
method,
251+
pathWithQuery,
252+
bodyHashHex,
253+
authRequestId,
254+
statusCode,
255+
rawToken,
256+
}: VerifyV4ResponseOptions): VerifyV4ResponseInfo {
257+
// Build the response preimage
258+
const preimage = calculateV4ResponsePreimage({
259+
timestampSec,
260+
method,
261+
pathWithQuery,
262+
statusCode,
263+
bodyHashHex,
264+
authRequestId,
265+
});
266+
267+
// Calculate expected HMAC
268+
const expectedHmac = hmacSha256(rawToken, preimage);
269+
270+
// Use constant-time comparison to prevent timing side-channel attacks
271+
const hmacBuffer = Buffer.from(hmac, 'hex');
272+
const expectedHmacBuffer = Buffer.from(expectedHmac, 'hex');
273+
const isHmacValid =
274+
hmacBuffer.length === expectedHmacBuffer.length && timingSafeEqual(hmacBuffer, expectedHmacBuffer);
275+
276+
// Check timestamp validity window
277+
const nowSec = getTimestampSec();
278+
const backwardValidityWindowSec = 5 * 60; // 5 minutes
279+
const forwardValidityWindowSec = 1 * 60; // 1 minute
280+
const isInResponseValidityWindow =
281+
timestampSec >= nowSec - backwardValidityWindowSec && timestampSec <= nowSec + forwardValidityWindowSec;
282+
283+
return {
284+
isValid: isHmacValid,
285+
expectedHmac,
286+
preimage,
287+
isInResponseValidityWindow,
288+
verificationTime: Date.now(),
289+
};
290+
}
291+
292+
/**
293+
* Extract path with query from x-original-uri header or request URL.
294+
*
295+
* @param xOriginalUri - Value of x-original-uri header (if present)
296+
* @param requestUrl - The actual request URL
297+
* @returns The path with query to use for preimage calculation
298+
*
299+
*/
300+
export function getPathWithQuery(xOriginalUri: string | undefined, requestUrl: string): string {
301+
// Prefer x-original-uri if available (proxy scenario)
302+
return xOriginalUri ?? requestUrl;
303+
}
304+
305+
/**
306+
* Get method from x-original-method header or actual request method.
307+
*
308+
* @param xOriginalMethod - Value of x-original-method header (if present)
309+
* @param requestMethod - The actual request method
310+
* @returns The method to use for preimage calculation
311+
*/
312+
export function getMethod(xOriginalMethod: string | undefined, requestMethod: string): string {
313+
// Prefer x-original-method if available (proxy scenario)
314+
return xOriginalMethod ?? requestMethod;
315+
}

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)