|
| 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 | +} |
0 commit comments