Skip to content

Commit 69a4784

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

6 files changed

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

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)