From cf9770f313824868aec6b051ec4f3672e9dfdb4a Mon Sep 17 00:00:00 2001 From: Mohammed Ryaan Date: Fri, 6 Feb 2026 17:57:06 +0530 Subject: [PATCH] feat(sdk-hmac): add v4 canonical preimage construction TICKET: CAAS-660 --- modules/sdk-hmac/src/hmac.ts | 5 +- modules/sdk-hmac/src/hmacv4.ts | 335 ++++++++++++++++ modules/sdk-hmac/src/index.ts | 2 + modules/sdk-hmac/src/types.ts | 57 +++ modules/sdk-hmac/src/util.ts | 82 ++++ modules/sdk-hmac/test/hmacv4.ts | 665 ++++++++++++++++++++++++++++++++ 6 files changed, 1144 insertions(+), 2 deletions(-) create mode 100644 modules/sdk-hmac/src/hmacv4.ts create mode 100644 modules/sdk-hmac/src/util.ts create mode 100644 modules/sdk-hmac/test/hmacv4.ts diff --git a/modules/sdk-hmac/src/hmac.ts b/modules/sdk-hmac/src/hmac.ts index 993ba94c73..740235f39c 100644 --- a/modules/sdk-hmac/src/hmac.ts +++ b/modules/sdk-hmac/src/hmac.ts @@ -1,4 +1,4 @@ -import { type BinaryLike, createHmac, type KeyObject } from 'crypto'; +import { type BinaryLike, type KeyObject } from 'crypto'; import * as urlLib from 'url'; import * as sjcl from '@bitgo/sjcl'; import { @@ -9,6 +9,7 @@ import { VerifyResponseInfo, VerifyResponseOptions, } from './types'; +import { hmacSha256 } from './util'; /** * Calculate the HMAC for the given key and message @@ -17,7 +18,7 @@ import { * @returns {*} - the result of the HMAC operation */ export function calculateHMAC(key: string | BinaryLike | KeyObject, message: string | BinaryLike): string { - return createHmac('sha256', key).update(message).digest('hex'); + return hmacSha256(key, message); } /** diff --git a/modules/sdk-hmac/src/hmacv4.ts b/modules/sdk-hmac/src/hmacv4.ts new file mode 100644 index 0000000000..e5b0ececa9 --- /dev/null +++ b/modules/sdk-hmac/src/hmacv4.ts @@ -0,0 +1,335 @@ +/** + * @prettier + * + * V4 HMAC Authentication Module + * + * This module implements the v4 authentication scheme which uses a canonical + * preimage construction with newline-separated fields and body hashing. + * + * Key differences from v2/v3: + * - Separator: newline (\n) instead of pipe (|) + * - Body: SHA256 hash of raw bytes instead of actual body content + * - Timestamp: seconds instead of milliseconds + * - New field: authRequestId for request tracking + * - Trailing newline in preimage + * - Support for x-original-* headers (proxy scenarios) + */ + +import { timingSafeEqual } from 'crypto'; +import { + hmacSha256, + sha256Hex, + normalizeMethod, + getTimestampSec, + extractPathWithQuery, + type HashableData, +} from './util'; +import { + CalculateV4PreimageOptions, + CalculateV4RequestHmacOptions, + CalculateV4RequestHeadersOptions, + V4RequestHeaders, + VerifyV4ResponseOptions, + VerifyV4ResponseInfo, +} from './types'; + +/** + * Build canonical preimage for v4 authentication. + * + * The preimage is constructed as newline-separated values with a trailing newline: + * ``` + * {timestampSec} + * {METHOD} + * {pathWithQuery} + * {bodyHashHex} + * {authRequestId} + * ``` + * + * This function normalizes the HTTP method to uppercase and handles the + * legacy 'del' method conversion to 'DELETE'. + * + * @param options - The preimage components + * @returns Newline-separated canonical preimage string with trailing newline + * + * @example + * ```typescript + * const preimage = calculateV4Preimage({ + * timestampSec: 1761100000, + * method: 'post', + * pathWithQuery: '/v2/wallets/transfer?foo=bar', + * bodyHashHex: '0d5e3b7a8f9c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e', + * authRequestId: '1b7a1d2b-7a2f-4e4b-a1f8-c2a5a0f84e3e', + * }); + * + * // Result: + * // "1761100000\nPOST\n/v2/wallets/transfer?foo=bar\n0d5e3b...d6e\n1b7a1d2b-7a2f-4e4b-a1f8-c2a5a0f84e3e\n" + * ``` + */ +export function calculateV4Preimage({ + timestampSec, + method, + pathWithQuery, + bodyHashHex, + authRequestId, +}: CalculateV4PreimageOptions): string { + const normalizedMethod = normalizeMethod(method); + + // Build newline-separated preimage with trailing newline + const components = [timestampSec.toString(), normalizedMethod, pathWithQuery, bodyHashHex, authRequestId]; + + return components.join('\n') + '\n'; +} + +/** + * Calculate SHA256 hash of body and return as lowercase hex string. + * + * This is used to compute the bodyHashHex field for v4 authentication. + * The hash is computed over the raw bytes of the request body, ensuring + * that the exact bytes sent over the wire are used for signature calculation. + * + * Accepts common byte representations for Node.js and browser environments, + * including Uint8Array and ArrayBuffer for Fetch API compatibility. + * + * @param body - Raw request body (string, Buffer, Uint8Array, or ArrayBuffer) + * @returns Lowercase hex SHA256 hash (64 characters) + * + * @example + * ```typescript + * // Node.js with Buffer + * const hash1 = calculateBodyHash(Buffer.from('{"address":"tb1q...","amount":100000}')); + * + * // Browser with Uint8Array + * const hash2 = calculateBodyHash(new TextEncoder().encode('{"address":"tb1q..."}')); + * + * // Browser with ArrayBuffer + * const hash3 = calculateBodyHash(await response.arrayBuffer()); + * + * // All return: '0d5e3b7a8f...' (64 character hex string) + * ``` + */ +export function calculateBodyHash(body: HashableData): string { + return sha256Hex(body); +} + +/** + * Calculate the HMAC-SHA256 signature for a v4 HTTP request. + * + * This function: + * 1. Builds the canonical preimage from the provided options + * 2. Computes HMAC-SHA256 of the preimage using the raw access token + * + * @param options - Request parameters and raw access token + * @returns Lowercase hex HMAC-SHA256 signature + * + * @example + * ```typescript + * const hmac = calculateV4RequestHmac({ + * timestampSec: 1761100000, + * method: 'POST', + * pathWithQuery: '/v2/wallets/transfer', + * bodyHashHex: '0d5e3b...', + * authRequestId: '1b7a1d2b-7a2f-4e4b-a1f8-c2a5a0f84e3e', + * rawToken: 'your-raw-token', + * }); + * ``` + */ +export function calculateV4RequestHmac({ + timestampSec, + method, + pathWithQuery, + bodyHashHex, + authRequestId, + rawToken, +}: CalculateV4RequestHmacOptions): string { + const preimage = calculateV4Preimage({ + timestampSec, + method, + pathWithQuery, + bodyHashHex, + authRequestId, + }); + + return hmacSha256(rawToken, preimage); +} + +/** + * Generate all headers required for v4 authenticated requests. + * + * This is a convenience function that: + * 1. Generates the current timestamp (in seconds) + * 2. Calculates the body hash from raw bytes + * 3. Computes the HMAC signature + * 4. Returns all values needed for request headers + * + * @param options - Request parameters including raw body and raw token + * @returns Object containing all v4 authentication header values + * + * @example + * ```typescript + * const headers = calculateV4RequestHeaders({ + * method: 'POST', + * pathWithQuery: '/v2/wallets/transfer?foo=bar', + * rawBody: Buffer.from('{"address":"tb1q..."}'), + * rawToken: 'your-token-key', + * authRequestId: '1b7a1d2b-7a2f-4e4b-a1f8-c2a5a0f84e3e', + * }); + * + * // Use headers to set: + * // - Auth-Timestamp: headers.timestampSec + * // - HMAC: headers.hmac + * // - X-Body-Hash: headers.bodyHashHex + * // - X-Request-Id: headers.authRequestId + * ``` + */ +export function calculateV4RequestHeaders({ + method, + pathWithQuery, + rawBody, + rawToken, + authRequestId, +}: CalculateV4RequestHeadersOptions): V4RequestHeaders { + const timestampSec = getTimestampSec(); + const bodyHashHex = calculateBodyHash(rawBody); + + const hmac = calculateV4RequestHmac({ + timestampSec, + method, + pathWithQuery, + bodyHashHex, + authRequestId, + rawToken, + }); + + return { + hmac, + timestampSec, + bodyHashHex, + authRequestId, + }; +} + +/** + * Build canonical preimage for v4 response verification. + * + * Response preimage includes the status code and uses the same format: + * ``` + * {timestampSec} + * {METHOD} + * {pathWithQuery} + * {statusCode} + * {bodyHashHex} + * {authRequestId} + * ``` + * + * @param options - Response verification parameters + * @returns Newline-separated canonical preimage string with trailing newline + */ +export function calculateV4ResponsePreimage({ + timestampSec, + method, + pathWithQuery, + statusCode, + bodyHashHex, + authRequestId, +}: Omit): string { + const normalizedMethod = normalizeMethod(method); + + const components = [ + timestampSec.toString(), + normalizedMethod, + pathWithQuery, + statusCode.toString(), + bodyHashHex, + authRequestId, + ]; + + return components.join('\n') + '\n'; +} + +/** + * Verify the HMAC signature of a v4 HTTP response. + * + * This function: + * 1. Reconstructs the canonical preimage from response data + * 2. Calculates the expected HMAC + * 3. Compares with the received HMAC + * 4. Checks if the timestamp is within the validity window + * + * The validity window is: + * - 5 minutes backwards (to account for clock skew and network latency) + * - 1 minute forwards (to account for minor clock differences) + * + * @param options - Response data and raw token for verification + * @returns Verification result including validity and diagnostic info + */ +export function verifyV4Response({ + hmac, + timestampSec, + method, + pathWithQuery, + bodyHashHex, + authRequestId, + statusCode, + rawToken, +}: VerifyV4ResponseOptions): VerifyV4ResponseInfo { + // Build the response preimage + const preimage = calculateV4ResponsePreimage({ + timestampSec, + method, + pathWithQuery, + statusCode, + bodyHashHex, + authRequestId, + }); + + // Calculate expected HMAC + const expectedHmac = hmacSha256(rawToken, preimage); + + // Use constant-time comparison to prevent timing side-channel attacks + const hmacBuffer = Buffer.from(hmac, 'hex'); + const expectedHmacBuffer = Buffer.from(expectedHmac, 'hex'); + const isHmacValid = + hmacBuffer.length === expectedHmacBuffer.length && timingSafeEqual(hmacBuffer, expectedHmacBuffer); + + // Check timestamp validity window + const nowSec = getTimestampSec(); + const backwardValidityWindowSec = 5 * 60; // 5 minutes + const forwardValidityWindowSec = 1 * 60; // 1 minute + const isInResponseValidityWindow = + timestampSec >= nowSec - backwardValidityWindowSec && timestampSec <= nowSec + forwardValidityWindowSec; + + return { + isValid: isHmacValid, + expectedHmac, + preimage, + isInResponseValidityWindow, + verificationTime: Date.now(), + }; +} + +/** + * Extract path with query from x-original-uri header or request URL. + * Always canonicalizes to pathname + search to handle absolute URLs. + * + * @param xOriginalUri - Value of x-original-uri header (if present) + * @param requestUrl - The actual request URL + * @returns The canonical path with query to use for preimage calculation + * + */ +export function getPathWithQuery(xOriginalUri: string | undefined, requestUrl: string): string { + // Prefer x-original-uri if available (proxy scenario) + const rawPath = xOriginalUri ?? requestUrl; + return extractPathWithQuery(rawPath); +} + +/** + * Get method from x-original-method header or actual request method. + * + * @param xOriginalMethod - Value of x-original-method header (if present) + * @param requestMethod - The actual request method + * @returns The method to use for preimage calculation + */ +export function getMethod(xOriginalMethod: string | undefined, requestMethod: string): string { + // Prefer x-original-method if available (proxy scenario) + return xOriginalMethod ?? requestMethod; +} diff --git a/modules/sdk-hmac/src/index.ts b/modules/sdk-hmac/src/index.ts index eddbbf391b..1e9631cf30 100644 --- a/modules/sdk-hmac/src/index.ts +++ b/modules/sdk-hmac/src/index.ts @@ -1,2 +1,4 @@ export * from './hmac'; +export * from './hmacv4'; +export * from './util'; export * from './types'; diff --git a/modules/sdk-hmac/src/types.ts b/modules/sdk-hmac/src/types.ts index 5d9db2066f..1cbb57f799 100644 --- a/modules/sdk-hmac/src/types.ts +++ b/modules/sdk-hmac/src/types.ts @@ -51,3 +51,60 @@ export interface VerifyResponseInfo { isInResponseValidityWindow: boolean; verificationTime: number; } + +export interface CalculateV4PreimageOptions { + timestampSec: number; + method: string; + pathWithQuery: string; + bodyHashHex: string; + authRequestId: string; +} + +export interface CalculateV4RequestHmacOptions extends CalculateV4PreimageOptions { + rawToken: string; +} + +import type { HashableData } from './util'; + +export interface CalculateV4RequestHeadersOptions { + method: string; + pathWithQuery: string; + rawBody: HashableData; + rawToken: string; + authRequestId: string; +} + +/** + * Headers generated for V4 authenticated requests. + */ +export interface V4RequestHeaders { + hmac: string; + timestampSec: number; + bodyHashHex: string; + authRequestId: string; +} + +/** + * Options for verifying V4 response HMAC. + */ +export interface VerifyV4ResponseOptions { + hmac: string; + timestampSec: number; + method: string; + pathWithQuery: string; + bodyHashHex: string; + authRequestId: string; + statusCode: number; + rawToken: string; +} + +/** + * Result of V4 response HMAC verification. + */ +export interface VerifyV4ResponseInfo { + isValid: boolean; + expectedHmac: string; + preimage: string; + isInResponseValidityWindow: boolean; + verificationTime: number; +} diff --git a/modules/sdk-hmac/src/util.ts b/modules/sdk-hmac/src/util.ts new file mode 100644 index 0000000000..0293a65cd8 --- /dev/null +++ b/modules/sdk-hmac/src/util.ts @@ -0,0 +1,82 @@ +import { createHash, createHmac, type BinaryLike, type KeyObject } from 'crypto'; + +export type HashableData = string | Buffer | Uint8Array | ArrayBuffer; + +/** + * Calculate SHA256 hash of data and return as lowercase hex string. + * Used for body hash calculation in v4 authentication. + * + * Accepts string, Buffer, Uint8Array, and ArrayBuffer for compatibility + * with both Node.js and browser environments. + * + * Note: ArrayBuffer is converted to Uint8Array internally since Node.js crypto + * requires TypedArray or DataView, not plain ArrayBuffer. + * + * @param data - The data to hash + * @returns Lowercase hex string of SHA256 hash + */ +export function sha256Hex(data: HashableData): string { + const normalizedData = data instanceof ArrayBuffer ? new Uint8Array(data) : data; + return createHash('sha256') + .update(normalizedData as BinaryLike) + .digest('hex'); +} + +/** + * Calculate HMAC-SHA256 and return as lowercase hex string. + * This is the core cryptographic primitive shared by v2/v3/v4 authentication. + * + * @param key - The secret key for HMAC + * @param message - The message to authenticate + * @returns Lowercase hex string of HMAC-SHA256 + */ +export function hmacSha256(key: string | BinaryLike | KeyObject, message: string | BinaryLike): string { + return createHmac('sha256', key).update(message).digest('hex'); +} + +/** + * Normalize HTTP method to uppercase. + * Handles legacy 'del' → 'DELETE' conversion for backward compatibility. + * + * @param method - HTTP method (case-insensitive) + * @returns Uppercase HTTP method + */ +export function normalizeMethod(method: string): string { + const lowerMethod = method.toLowerCase(); + if (lowerMethod === 'del') { + return 'DELETE'; + } + return method.toUpperCase(); +} + +/** + * Extract path with query string from a URL. + * Handles both absolute URLs and relative paths. + * + * @param urlPath - Full URL or relative path + * @returns Path with query string (e.g., '/api/v2/wallet?foo=bar') + */ +export function extractPathWithQuery(urlPath: string): string { + try { + // Try parsing as absolute URL first + const url = new URL(urlPath); + return url.pathname + url.search; + } catch { + try { + const url = new URL(urlPath, 'http://localhost'); + return url.pathname + url.search; + } catch { + return urlPath; + } + } +} + +/** + * Get current timestamp in seconds (Unix epoch). + * Used for v4 authentication which uses seconds instead of milliseconds. + * + * @returns Current Unix timestamp in seconds + */ +export function getTimestampSec(): number { + return Math.floor(Date.now() / 1000); +} diff --git a/modules/sdk-hmac/test/hmacv4.ts b/modules/sdk-hmac/test/hmacv4.ts new file mode 100644 index 0000000000..d73f036c49 --- /dev/null +++ b/modules/sdk-hmac/test/hmacv4.ts @@ -0,0 +1,665 @@ +/** + * @prettier + */ +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { + calculateV4Preimage, + calculateBodyHash, + calculateV4RequestHmac, + calculateV4RequestHeaders, + calculateV4ResponsePreimage, + verifyV4Response, + getPathWithQuery, + getMethod, +} from '../src/hmacv4'; +import { sha256Hex, hmacSha256, normalizeMethod, getTimestampSec } from '../src/util'; + +const MOCK_TIMESTAMP_SEC = 1761100000; +const MOCK_TIMESTAMP_MS = MOCK_TIMESTAMP_SEC * 1000; + +describe('V4 HMAC Authentication', () => { + let clock: sinon.SinonFakeTimers; + + before(() => { + clock = sinon.useFakeTimers(MOCK_TIMESTAMP_MS); + }); + + after(() => { + clock.restore(); + }); + + describe('Helper Functions', () => { + describe('sha256Hex', () => { + it('should calculate correct SHA256 hash for string input', () => { + const input = '{"address":"tb1qtest","amount":100000}'; + const hash = sha256Hex(input); + + expect(hash).to.be.a('string'); + expect(hash).to.have.lengthOf(64); // SHA256 produces 64 hex chars + expect(hash).to.match(/^[0-9a-f]+$/); // Lowercase hex + }); + + it('should calculate correct SHA256 hash for Buffer input', () => { + const input = Buffer.from('{"address":"tb1qtest","amount":100000}'); + const hash = sha256Hex(input); + + expect(hash).to.have.lengthOf(64); + expect(hash).to.match(/^[0-9a-f]+$/); + }); + + it('should produce same hash for same content in string and Buffer', () => { + const content = '{"test":"data"}'; + const hashFromString = sha256Hex(content); + const hashFromBuffer = sha256Hex(Buffer.from(content)); + + expect(hashFromString).to.equal(hashFromBuffer); + }); + + it('should produce empty string hash for empty input', () => { + const emptyHash = sha256Hex(''); + // SHA256 of empty string is a known constant + expect(emptyHash).to.equal('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'); + }); + }); + + describe('normalizeMethod', () => { + it('should convert lowercase methods to uppercase', () => { + expect(normalizeMethod('get')).to.equal('GET'); + expect(normalizeMethod('post')).to.equal('POST'); + expect(normalizeMethod('put')).to.equal('PUT'); + expect(normalizeMethod('patch')).to.equal('PATCH'); + expect(normalizeMethod('delete')).to.equal('DELETE'); + expect(normalizeMethod('options')).to.equal('OPTIONS'); + }); + + it('should handle legacy "del" method', () => { + expect(normalizeMethod('del')).to.equal('DELETE'); + expect(normalizeMethod('DEL')).to.equal('DELETE'); + }); + + it('should preserve already uppercase methods', () => { + expect(normalizeMethod('POST')).to.equal('POST'); + expect(normalizeMethod('GET')).to.equal('GET'); + }); + }); + + describe('getTimestampSec', () => { + it('should return timestamp in seconds', () => { + const ts = getTimestampSec(); + expect(ts).to.equal(MOCK_TIMESTAMP_SEC); + }); + }); + }); + + describe('calculateBodyHash', () => { + it('should calculate SHA256 hash of request body', () => { + const body = '{"address":"tb1qtest","amount":100000}'; + const hash = calculateBodyHash(body); + + expect(hash).to.have.lengthOf(64); + expect(hash).to.match(/^[0-9a-f]+$/); + }); + + it('should handle Buffer input', () => { + const body = Buffer.from('{"address":"tb1qtest","amount":100000}'); + const hash = calculateBodyHash(body); + + expect(hash).to.have.lengthOf(64); + }); + + it('should handle Uint8Array input (browser compatibility)', () => { + const body = new Uint8Array(Buffer.from('{"address":"tb1qtest","amount":100000}')); + const hash = calculateBodyHash(body); + + expect(hash).to.have.lengthOf(64); + expect(hash).to.match(/^[0-9a-f]+$/); + }); + + it('should handle ArrayBuffer input (browser compatibility)', () => { + const buffer = Buffer.from('{"address":"tb1qtest","amount":100000}'); + const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); + const hash = calculateBodyHash(arrayBuffer); + + expect(hash).to.have.lengthOf(64); + expect(hash).to.match(/^[0-9a-f]+$/); + }); + + it('should produce consistent hash for same content', () => { + const body = '{"test":"value"}'; + const hash1 = calculateBodyHash(body); + const hash2 = calculateBodyHash(body); + + expect(hash1).to.equal(hash2); + }); + + it('should produce same hash for string, Buffer, Uint8Array, and ArrayBuffer', () => { + const content = '{"test":"data"}'; + const hashFromString = calculateBodyHash(content); + const hashFromBuffer = calculateBodyHash(Buffer.from(content)); + const hashFromUint8Array = calculateBodyHash(new Uint8Array(Buffer.from(content))); + const buffer = Buffer.from(content); + const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); + const hashFromArrayBuffer = calculateBodyHash(arrayBuffer); + + expect(hashFromString).to.equal(hashFromBuffer); + expect(hashFromString).to.equal(hashFromUint8Array); + expect(hashFromString).to.equal(hashFromArrayBuffer); + }); + }); + + describe('calculateV4Preimage', () => { + it('should build correct canonical preimage', () => { + const preimage = calculateV4Preimage({ + timestampSec: 1761100000, + method: 'post', + pathWithQuery: '/v2/wallets/transfer?foo=bar', + bodyHashHex: '0d5e3b7a8f9c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e', + authRequestId: '1b7a1d2b-7a2f-4e4b-a1f8-c2a5a0f84e3e', + }); + + const expectedPreimage = + '1761100000\n' + + 'POST\n' + + '/v2/wallets/transfer?foo=bar\n' + + '0d5e3b7a8f9c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e\n' + + '1b7a1d2b-7a2f-4e4b-a1f8-c2a5a0f84e3e\n'; + + expect(preimage).to.equal(expectedPreimage); + }); + + it('should normalize method to uppercase', () => { + const preimage = calculateV4Preimage({ + timestampSec: 1761100000, + method: 'post', // lowercase + pathWithQuery: '/api/test', + bodyHashHex: 'abc123', + authRequestId: 'req-123', + }); + + expect(preimage).to.include('\nPOST\n'); + }); + + it('should handle legacy "del" method', () => { + const preimage = calculateV4Preimage({ + timestampSec: 1761100000, + method: 'del', + pathWithQuery: '/api/test', + bodyHashHex: 'abc123', + authRequestId: 'req-123', + }); + + expect(preimage).to.include('\nDELETE\n'); + }); + + it('should include trailing newline', () => { + const preimage = calculateV4Preimage({ + timestampSec: 1761100000, + method: 'GET', + pathWithQuery: '/api/test', + bodyHashHex: 'abc123', + authRequestId: 'req-123', + }); + + expect(preimage.endsWith('\n')).to.be.true; + }); + + it('should have exactly 5 newlines (5 fields + trailing)', () => { + const preimage = calculateV4Preimage({ + timestampSec: 1761100000, + method: 'GET', + pathWithQuery: '/api/test', + bodyHashHex: 'abc123', + authRequestId: 'req-123', + }); + + const newlineCount = (preimage.match(/\n/g) || []).length; + expect(newlineCount).to.equal(5); + }); + + it('should preserve query parameters in path', () => { + const preimage = calculateV4Preimage({ + timestampSec: 1761100000, + method: 'GET', + pathWithQuery: '/api/v2/wallet?limit=10&offset=0', + bodyHashHex: 'abc123', + authRequestId: 'req-123', + }); + + expect(preimage).to.include('/api/v2/wallet?limit=10&offset=0\n'); + }); + }); + + describe('calculateV4RequestHmac', () => { + it('should calculate HMAC for a request', () => { + const hmac = calculateV4RequestHmac({ + timestampSec: 1761100000, + method: 'POST', + pathWithQuery: '/v2/wallets/transfer', + bodyHashHex: 'abc123def456', + authRequestId: '1b7a1d2b-7a2f-4e4b-a1f8-c2a5a0f84e3e', + rawToken: 'test-raw-token', + }); + + expect(hmac).to.be.a('string'); + expect(hmac).to.have.lengthOf(64); // HMAC-SHA256 produces 64 hex chars + expect(hmac).to.match(/^[0-9a-f]+$/); + }); + + it('should produce different HMACs for different raw tokens', () => { + const baseOptions = { + timestampSec: 1761100000, + method: 'POST', + pathWithQuery: '/v2/wallets/transfer', + bodyHashHex: 'abc123', + authRequestId: 'req-123', + }; + + const hmac1 = calculateV4RequestHmac({ ...baseOptions, rawToken: 'token1' }); + const hmac2 = calculateV4RequestHmac({ ...baseOptions, rawToken: 'token2' }); + + expect(hmac1).to.not.equal(hmac2); + }); + + it('should produce different HMACs for different timestamps', () => { + const baseOptions = { + method: 'POST', + pathWithQuery: '/v2/wallets/transfer', + bodyHashHex: 'abc123', + authRequestId: 'req-123', + rawToken: 'test-key', + }; + + const hmac1 = calculateV4RequestHmac({ ...baseOptions, timestampSec: 1761100000 }); + const hmac2 = calculateV4RequestHmac({ ...baseOptions, timestampSec: 1761100001 }); + + expect(hmac1).to.not.equal(hmac2); + }); + + it('should produce consistent HMAC for same inputs', () => { + const options = { + timestampSec: 1761100000, + method: 'POST', + pathWithQuery: '/v2/wallets/transfer', + bodyHashHex: 'abc123', + authRequestId: 'req-123', + rawToken: 'test-key', + }; + + const hmac1 = calculateV4RequestHmac(options); + const hmac2 = calculateV4RequestHmac(options); + + expect(hmac1).to.equal(hmac2); + }); + }); + + describe('calculateV4RequestHeaders', () => { + it('should generate all required headers', () => { + const headers = calculateV4RequestHeaders({ + method: 'POST', + pathWithQuery: '/v2/wallets/transfer', + rawBody: '{"address":"tb1qtest"}', + rawToken: 'test-raw-token', + authRequestId: '1b7a1d2b-7a2f-4e4b-a1f8-c2a5a0f84e3e', + }); + + expect(headers).to.have.property('hmac'); + expect(headers).to.have.property('timestampSec'); + expect(headers).to.have.property('bodyHashHex'); + expect(headers).to.have.property('authRequestId'); + }); + + it('should use current timestamp in seconds', () => { + const headers = calculateV4RequestHeaders({ + method: 'POST', + pathWithQuery: '/v2/wallets/transfer', + rawBody: '{}', + rawToken: 'test-key', + authRequestId: 'req-123', + }); + + expect(headers.timestampSec).to.equal(MOCK_TIMESTAMP_SEC); + }); + + it('should calculate correct body hash from raw body', () => { + const rawBody = '{"address":"tb1qtest"}'; + const expectedHash = sha256Hex(rawBody); + + const headers = calculateV4RequestHeaders({ + method: 'POST', + pathWithQuery: '/v2/wallets/transfer', + rawBody, + rawToken: 'test-key', + authRequestId: 'req-123', + }); + + expect(headers.bodyHashHex).to.equal(expectedHash); + }); + + it('should preserve authRequestId in headers', () => { + const authRequestId = '1b7a1d2b-7a2f-4e4b-a1f8-c2a5a0f84e3e'; + + const headers = calculateV4RequestHeaders({ + method: 'POST', + pathWithQuery: '/v2/wallets/transfer', + rawBody: '{}', + rawToken: 'test-key', + authRequestId, + }); + + expect(headers.authRequestId).to.equal(authRequestId); + }); + + it('should handle Buffer raw body', () => { + const rawBody = Buffer.from('{"address":"tb1qtest"}'); + const expectedHash = sha256Hex(rawBody); + + const headers = calculateV4RequestHeaders({ + method: 'POST', + pathWithQuery: '/v2/wallets/transfer', + rawBody, + rawToken: 'test-key', + authRequestId: 'req-123', + }); + + expect(headers.bodyHashHex).to.equal(expectedHash); + }); + + it('should handle Uint8Array raw body (browser compatibility)', () => { + const rawBody = new Uint8Array(Buffer.from('{"address":"tb1qtest"}')); + const expectedHash = sha256Hex(rawBody); + + const headers = calculateV4RequestHeaders({ + method: 'POST', + pathWithQuery: '/v2/wallets/transfer', + rawBody, + rawToken: 'test-key', + authRequestId: 'req-123', + }); + + expect(headers.bodyHashHex).to.equal(expectedHash); + expect(headers).to.have.property('hmac'); + expect(headers.hmac).to.have.lengthOf(64); + }); + + it('should handle ArrayBuffer raw body (browser compatibility)', () => { + const buffer = Buffer.from('{"address":"tb1qtest"}'); + const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); + const expectedHash = sha256Hex(arrayBuffer); + + const headers = calculateV4RequestHeaders({ + method: 'POST', + pathWithQuery: '/v2/wallets/transfer', + rawBody: arrayBuffer, + rawToken: 'test-key', + authRequestId: 'req-123', + }); + + expect(headers.bodyHashHex).to.equal(expectedHash); + expect(headers).to.have.property('hmac'); + expect(headers.hmac).to.have.lengthOf(64); + }); + }); + + describe('calculateV4ResponsePreimage', () => { + it('should include status code in response preimage', () => { + const preimage = calculateV4ResponsePreimage({ + timestampSec: 1761100000, + method: 'POST', + pathWithQuery: '/v2/wallets/transfer', + statusCode: 200, + bodyHashHex: 'abc123', + authRequestId: 'req-123', + }); + + const expectedPreimage = + '1761100000\n' + 'POST\n' + '/v2/wallets/transfer\n' + '200\n' + 'abc123\n' + 'req-123\n'; + + expect(preimage).to.equal(expectedPreimage); + }); + + it('should have exactly 6 newlines for response (6 fields + trailing)', () => { + const preimage = calculateV4ResponsePreimage({ + timestampSec: 1761100000, + method: 'GET', + pathWithQuery: '/api/test', + statusCode: 200, + bodyHashHex: 'abc123', + authRequestId: 'req-123', + }); + + const newlineCount = (preimage.match(/\n/g) || []).length; + expect(newlineCount).to.equal(6); + }); + }); + + describe('verifyV4Response', () => { + it('should verify valid response HMAC', () => { + // First generate a valid HMAC + const options = { + timestampSec: MOCK_TIMESTAMP_SEC, + method: 'POST', + pathWithQuery: '/v2/wallets/transfer', + statusCode: 200, + bodyHashHex: 'abc123', + authRequestId: 'req-123', + rawToken: 'test-key', + }; + + const preimage = calculateV4ResponsePreimage(options); + const validHmac = hmacSha256(options.rawToken, preimage); + const result = verifyV4Response({ + ...options, + hmac: validHmac, + }); + + expect(result.isValid).to.be.true; + expect(result.isInResponseValidityWindow).to.be.true; + }); + + it('should reject invalid HMAC', () => { + const result = verifyV4Response({ + timestampSec: MOCK_TIMESTAMP_SEC, + method: 'POST', + pathWithQuery: '/v2/wallets/transfer', + statusCode: 200, + bodyHashHex: 'abc123', + authRequestId: 'req-123', + rawToken: 'test-key', + hmac: 'invalid-hmac', + }); + + expect(result.isValid).to.be.false; + }); + + it('should reject timestamp outside backward validity window (5 min)', () => { + const oldTimestamp = MOCK_TIMESTAMP_SEC - 6 * 60; // 6 minutes ago + + const preimage = calculateV4ResponsePreimage({ + timestampSec: oldTimestamp, + method: 'POST', + pathWithQuery: '/v2/wallets/transfer', + statusCode: 200, + bodyHashHex: 'abc123', + authRequestId: 'req-123', + }); + const validHmac = hmacSha256('test-key', preimage); + + const result = verifyV4Response({ + timestampSec: oldTimestamp, + method: 'POST', + pathWithQuery: '/v2/wallets/transfer', + statusCode: 200, + bodyHashHex: 'abc123', + authRequestId: 'req-123', + rawToken: 'test-key', + hmac: validHmac, + }); + + expect(result.isValid).to.be.true; // HMAC is correct + expect(result.isInResponseValidityWindow).to.be.false; // But timestamp is old + }); + + it('should reject timestamp outside forward validity window (1 min)', () => { + const futureTimestamp = MOCK_TIMESTAMP_SEC + 2 * 60; // 2 minutes in future + + const preimage = calculateV4ResponsePreimage({ + timestampSec: futureTimestamp, + method: 'POST', + pathWithQuery: '/v2/wallets/transfer', + statusCode: 200, + bodyHashHex: 'abc123', + authRequestId: 'req-123', + }); + const validHmac = hmacSha256('test-key', preimage); + + const result = verifyV4Response({ + timestampSec: futureTimestamp, + method: 'POST', + pathWithQuery: '/v2/wallets/transfer', + statusCode: 200, + bodyHashHex: 'abc123', + authRequestId: 'req-123', + rawToken: 'test-key', + hmac: validHmac, + }); + + expect(result.isValid).to.be.true; + expect(result.isInResponseValidityWindow).to.be.false; + }); + + it('should accept timestamp within forward validity window', () => { + const nearFutureTimestamp = MOCK_TIMESTAMP_SEC + 30; // 30 seconds in future + + const preimage = calculateV4ResponsePreimage({ + timestampSec: nearFutureTimestamp, + method: 'POST', + pathWithQuery: '/v2/wallets/transfer', + statusCode: 200, + bodyHashHex: 'abc123', + authRequestId: 'req-123', + }); + const validHmac = hmacSha256('test-key', preimage); + + const result = verifyV4Response({ + timestampSec: nearFutureTimestamp, + method: 'POST', + pathWithQuery: '/v2/wallets/transfer', + statusCode: 200, + bodyHashHex: 'abc123', + authRequestId: 'req-123', + rawToken: 'test-key', + hmac: validHmac, + }); + + expect(result.isValid).to.be.true; + expect(result.isInResponseValidityWindow).to.be.true; + }); + + it('should return expected HMAC and preimage in result', () => { + const options = { + timestampSec: MOCK_TIMESTAMP_SEC, + method: 'POST', + pathWithQuery: '/v2/wallets/transfer', + statusCode: 200, + bodyHashHex: 'abc123', + authRequestId: 'req-123', + rawToken: 'test-key', + }; + + const expectedPreimage = calculateV4ResponsePreimage(options); + const expectedHmac = hmacSha256(options.rawToken, expectedPreimage); + const result = verifyV4Response({ + ...options, + hmac: expectedHmac, + }); + + expect(result.expectedHmac).to.equal(expectedHmac); + expect(result.preimage).to.equal(expectedPreimage); + expect(result.verificationTime).to.be.a('number'); + }); + }); + + describe('Proxy Header Helpers', () => { + describe('getPathWithQuery', () => { + it('should return x-original-uri when provided', () => { + const result = getPathWithQuery('/v2/wallets/transfer?foo=bar', '/internal/proxy'); + + expect(result).to.equal('/v2/wallets/transfer?foo=bar'); + }); + + it('should return request URL when x-original-uri is undefined', () => { + const result = getPathWithQuery(undefined, '/api/v2/wallet'); + + expect(result).to.equal('/api/v2/wallet'); + }); + }); + + describe('getMethod', () => { + it('should return x-original-method when provided', () => { + const result = getMethod('POST', 'GET'); + + expect(result).to.equal('POST'); + }); + + it('should return request method when x-original-method is undefined', () => { + const result = getMethod(undefined, 'DELETE'); + + expect(result).to.equal('DELETE'); + }); + }); + }); + + describe('Integration: Full Request Signing and Verification', () => { + it('should successfully sign and verify a complete request-response cycle', () => { + const rawToken = 'my-secret-signing-key'; + const rawRequestBody = Buffer.from('{"address":"tb1qtest","amount":100000}'); + const authRequestId = '550e8400-e29b-41d4-a716-446655440000'; + const pathWithQuery = '/v2/wallets/abc123/transfer?coin=btc'; + const method = 'POST'; + + // Step 1: Client generates request headers + const requestHeaders = calculateV4RequestHeaders({ + method, + pathWithQuery, + rawBody: rawRequestBody, + rawToken, + authRequestId, + }); + + expect(requestHeaders.timestampSec).to.equal(MOCK_TIMESTAMP_SEC); + expect(requestHeaders.authRequestId).to.equal(authRequestId); + + // Step 2: Simulate server processing and response + const responseBody = '{"txid":"abc123def456","status":"completed"}'; + const responseBodyHash = calculateBodyHash(responseBody); + const statusCode = 200; + + // Step 3: Server generates response HMAC + const responsePreimage = calculateV4ResponsePreimage({ + timestampSec: requestHeaders.timestampSec, + method, + pathWithQuery, + statusCode, + bodyHashHex: responseBodyHash, + authRequestId, + }); + const responseHmac = hmacSha256(rawToken, responsePreimage); + + // Step 4: Client verifies response + const verificationResult = verifyV4Response({ + hmac: responseHmac, + timestampSec: requestHeaders.timestampSec, + method, + pathWithQuery, + statusCode, + bodyHashHex: responseBodyHash, + authRequestId, + rawToken, + }); + + expect(verificationResult.isValid).to.be.true; + expect(verificationResult.isInResponseValidityWindow).to.be.true; + }); + }); +});