diff --git a/Dockerfile b/Dockerfile index d2740a08c1..7d7bb89f87 100644 --- a/Dockerfile +++ b/Dockerfile @@ -52,6 +52,7 @@ COPY --from=builder /tmp/bitgo/modules/abstract-lightning /var/modules/abstract- COPY --from=builder /tmp/bitgo/modules/abstract-utxo /var/modules/abstract-utxo/ COPY --from=builder /tmp/bitgo/modules/blockapis /var/modules/blockapis/ COPY --from=builder /tmp/bitgo/modules/sdk-api /var/modules/sdk-api/ +COPY --from=builder /tmp/bitgo/modules/sdk-hmac /var/modules/sdk-hmac/ COPY --from=builder /tmp/bitgo/modules/unspents /var/modules/unspents/ COPY --from=builder /tmp/bitgo/modules/account-lib /var/modules/account-lib/ COPY --from=builder /tmp/bitgo/modules/sdk-coin-algo /var/modules/sdk-coin-algo/ @@ -126,6 +127,7 @@ cd /var/modules/abstract-lightning && yarn link && \ cd /var/modules/abstract-utxo && yarn link && \ cd /var/modules/blockapis && yarn link && \ cd /var/modules/sdk-api && yarn link && \ +cd /var/modules/sdk-hmac && yarn link && \ cd /var/modules/unspents && yarn link && \ cd /var/modules/account-lib && yarn link && \ cd /var/modules/sdk-coin-algo && yarn link && \ @@ -203,6 +205,7 @@ RUN cd /var/bitgo-express && \ yarn link @bitgo/abstract-utxo && \ yarn link @bitgo/blockapis && \ yarn link @bitgo/sdk-api && \ + yarn link @bitgo/sdk-hmac && \ yarn link @bitgo/unspents && \ yarn link @bitgo/account-lib && \ yarn link @bitgo/sdk-coin-algo && \ diff --git a/modules/bitgo/test/v2/unit/auth.ts b/modules/bitgo/test/v2/unit/auth.ts index 29436233d5..b9641e4521 100644 --- a/modules/bitgo/test/v2/unit/auth.ts +++ b/modules/bitgo/test/v2/unit/auth.ts @@ -5,6 +5,13 @@ import * as sinon from 'sinon'; import { BitGo } from '../../../src'; describe('Auth', () => { + let sandbox; + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + afterEach(() => { + sandbox.restore(); + }); describe('Auth V3', () => { it('should set auth version to 3 when initializing a bitgo object with explicit auth version 3', () => { const bitgo = new BitGo({ authVersion: 3 }); @@ -74,7 +81,10 @@ describe('Auth', () => { const accessToken = `v2x${'0'.repeat(64)}`; const bitgo = new BitGo({ authVersion: 3, accessToken }); - const calculateHMACSpy = sinon.spy(bitgo, 'calculateHMAC'); + const crypto = require('crypto'); + const createHmacSpy = sinon.spy(crypto, 'createHmac'); + const updateSpy = sinon.spy(crypto.Hmac.prototype, 'update'); + const verifyResponseStub = sinon.stub(bitgo, 'verifyResponse').returns({ isValid: true, isInResponseValidityWindow: true, @@ -86,8 +96,10 @@ describe('Auth', () => { const scope = nock(url).get('/').reply(200); await bitgo.get(url).should.eventually.have.property('status', 200); - calculateHMACSpy.firstCall.calledWith(accessToken, sinon.match('3.0')).should.be.true(); - calculateHMACSpy.restore(); + + createHmacSpy.firstCall.calledWith('sha256', accessToken).should.be.true(); + updateSpy.firstCall.calledWith(sinon.match('3.0')).should.be.true(); + createHmacSpy.restore(); verifyResponseStub.restore(); scope.done(); }); diff --git a/modules/bitgo/tsconfig.json b/modules/bitgo/tsconfig.json index 7cdf3477ea..6bfb378fec 100644 --- a/modules/bitgo/tsconfig.json +++ b/modules/bitgo/tsconfig.json @@ -60,6 +60,9 @@ { "path": "../sdk-api" }, + { + "path": "../sdk-hmac" + }, { "path": "../sdk-coin-ada" }, diff --git a/modules/sdk-api/package.json b/modules/sdk-api/package.json index bb67ee14d6..7fc1a0c01f 100644 --- a/modules/sdk-api/package.json +++ b/modules/sdk-api/package.json @@ -41,6 +41,7 @@ }, "dependencies": { "@bitgo/sdk-core": "^28.18.0", + "@bitgo/sdk-hmac": "^1.0.0", "@bitgo/sjcl": "^1.0.1", "@bitgo/unspents": "^0.47.17", "@bitgo/utxo-lib": "^11.2.1", diff --git a/modules/sdk-api/src/api.ts b/modules/sdk-api/src/api.ts index 064d058bfe..554a5fecb1 100644 --- a/modules/sdk-api/src/api.ts +++ b/modules/sdk-api/src/api.ts @@ -11,7 +11,7 @@ import querystring from 'querystring'; import { ApiResponseError, BitGoRequest } from '@bitgo/sdk-core'; -import { VerifyResponseOptions } from './types'; +import { AuthVersion, VerifyResponseOptions } from './types'; import { BitGoAPI } from './bitgoAPI'; const debug = Debug('bitgo:api'); @@ -178,7 +178,8 @@ export function verifyResponse( token: string | undefined, method: VerifyResponseOptions['method'], req: superagent.SuperAgentRequest, - response: superagent.Response + response: superagent.Response, + authVersion: AuthVersion ): superagent.Response { // we can't verify the response if we're not authenticated if (!req.isV2Authenticated || !req.authenticationToken) { @@ -193,6 +194,7 @@ export function verifyResponse( timestamp: response.header.timestamp, token: req.authenticationToken, method, + authVersion, }); if (!verificationResponse.isValid) { diff --git a/modules/sdk-api/src/bitgoAPI.ts b/modules/sdk-api/src/bitgoAPI.ts index fbaa381222..a6d07356b5 100644 --- a/modules/sdk-api/src/bitgoAPI.ts +++ b/modules/sdk-api/src/bitgoAPI.ts @@ -21,17 +21,15 @@ import { makeRandomKey, sanitizeLegacyPath, } from '@bitgo/sdk-core'; -import * as sjcl from '@bitgo/sjcl'; +import * as sdkHmac from '@bitgo/sdk-hmac'; import * as utxolib from '@bitgo/utxo-lib'; import { bip32, ECPairInterface } from '@bitgo/utxo-lib'; import * as bitcoinMessage from 'bitcoinjs-message'; -import { createHmac } from 'crypto'; import { type Agent } from 'http'; import debugLib from 'debug'; import * as _ from 'lodash'; import * as secp256k1 from 'secp256k1'; import * as superagent from 'superagent'; -import * as urlLib from 'url'; import { handleResponseError, handleResponseResult, @@ -396,6 +394,7 @@ export class BitGoAPI implements BitGoBase { token: this._token, method, text: data || '', + authVersion: this._authVersion, }); req.set('Auth-Timestamp', requestProperties.timestamp.toString()); @@ -420,7 +419,7 @@ export class BitGoAPI implements BitGoBase { return onfulfilled(response); } - const verifiedResponse = verifyResponse(this, this._token, method, req, response); + const verifiedResponse = verifyResponse(this, this._token, method, req, response, this._authVersion); return onfulfilled(verifiedResponse); } : null; @@ -455,7 +454,7 @@ export class BitGoAPI implements BitGoBase { * @returns {*} - the result of the HMAC operation */ calculateHMAC(key: string, message: string): string { - return createHmac('sha256', key).update(message).digest('hex'); + return sdkHmac.calculateHMAC(key, message); } /** @@ -467,83 +466,29 @@ export class BitGoAPI implements BitGoBase { * @param method request method * @returns {string} */ - calculateHMACSubject({ urlPath, text, timestamp, statusCode, method }: CalculateHmacSubjectOptions): string { - const urlDetails = urlLib.parse(urlPath); - const queryPath = urlDetails.query && urlDetails.query.length > 0 ? urlDetails.path : urlDetails.pathname; - if (!_.isUndefined(statusCode) && _.isInteger(statusCode) && _.isFinite(statusCode)) { - if (this._authVersion === 3) { - return [method.toUpperCase(), timestamp, queryPath, statusCode, text].join('|'); - } - return [timestamp, queryPath, statusCode, text].join('|'); - } - if (this._authVersion === 3) { - return [method.toUpperCase(), timestamp, '3.0', queryPath, text].join('|'); - } - return [timestamp, queryPath, text].join('|'); + calculateHMACSubject(params: CalculateHmacSubjectOptions): string { + return sdkHmac.calculateHMACSubject({ ...params, authVersion: this._authVersion }); } /** * Calculate the HMAC for an HTTP request */ - calculateRequestHMAC({ url: urlPath, text, timestamp, token, method }: CalculateRequestHmacOptions): string { - const signatureSubject = this.calculateHMACSubject({ urlPath, text, timestamp, method }); - - // calculate the HMAC - return this.calculateHMAC(token, signatureSubject); + calculateRequestHMAC(params: CalculateRequestHmacOptions): string { + return sdkHmac.calculateRequestHMAC({ ...params, authVersion: this._authVersion }); } /** * Calculate request headers with HMAC */ - calculateRequestHeaders({ url, text, token, method }: CalculateRequestHeadersOptions): RequestHeaders { - const timestamp = Date.now(); - const hmac = this.calculateRequestHMAC({ url, text, timestamp, token, method }); - - // calculate the SHA256 hash of the token - const hashDigest = sjcl.hash.sha256.hash(token); - const tokenHash = sjcl.codec.hex.fromBits(hashDigest); - return { - hmac, - timestamp, - tokenHash, - }; + calculateRequestHeaders(params: CalculateRequestHeadersOptions): RequestHeaders { + return sdkHmac.calculateRequestHeaders({ ...params, authVersion: this._authVersion }); } /** * Verify the HMAC for an HTTP response */ - verifyResponse({ - url: urlPath, - statusCode, - text, - timestamp, - token, - hmac, - method, - }: VerifyResponseOptions): VerifyResponseInfo { - const signatureSubject = this.calculateHMACSubject({ - urlPath, - text, - timestamp, - statusCode, - method, - }); - - // calculate the HMAC - const expectedHmac = this.calculateHMAC(token, signatureSubject); - - // determine if the response is still within the validity window (5 minute window) - const now = Date.now(); - const isInResponseValidityWindow = timestamp >= now - 1000 * 60 * 5 && timestamp <= now; - - // verify the HMAC and timestamp - return { - isValid: expectedHmac === hmac, - expectedHmac, - signatureSubject, - isInResponseValidityWindow, - verificationTime: now, - }; + verifyResponse(params: VerifyResponseOptions): VerifyResponseInfo { + return sdkHmac.verifyResponse({ ...params, authVersion: this._authVersion }); } /** @@ -904,7 +849,7 @@ export class BitGoAPI implements BitGoBase { this._ecdhXprv = responseDetails.ecdhXprv; // verify the response's authenticity - verifyResponse(this, responseDetails.token, 'post', request, response); + verifyResponse(this, responseDetails.token, 'post', request, response, this._authVersion); // add the remaining component for easier access response.body.access_token = this._token; @@ -1186,7 +1131,7 @@ export class BitGoAPI implements BitGoBase { } // verify the authenticity of the server's response before proceeding any further - verifyResponse(this, this._token, 'post', request, response); + verifyResponse(this, this._token, 'post', request, response, this._authVersion); const responseDetails = this.handleTokenIssuance(response.body); response.body.token = responseDetails.token; diff --git a/modules/sdk-api/src/types.ts b/modules/sdk-api/src/types.ts index 24f23ba897..c258388a16 100644 --- a/modules/sdk-api/src/types.ts +++ b/modules/sdk-api/src/types.ts @@ -1,7 +1,16 @@ import { EnvironmentName, IRequestTracer, V1Network } from '@bitgo/sdk-core'; import { ECPairInterface } from '@bitgo/utxo-lib'; import { type Agent } from 'http'; - +export { + supportedRequestMethods, + AuthVersion, + CalculateHmacSubjectOptions, + CalculateRequestHmacOptions, + CalculateRequestHeadersOptions, + RequestHeaders, + VerifyResponseOptions, + VerifyResponseInfo, +} from '@bitgo/sdk-hmac'; export interface BitGoAPIOptions { accessToken?: string; authVersion?: 2 | 3; @@ -36,54 +45,6 @@ export interface PingOptions { reqId?: IRequestTracer; } -export const supportedRequestMethods = ['get', 'post', 'put', 'del', 'patch', 'options'] as const; - -export interface CalculateHmacSubjectOptions { - urlPath: string; - text: string; - timestamp: number; - method: (typeof supportedRequestMethods)[number]; - statusCode?: number; -} - -export interface CalculateRequestHmacOptions { - url: string; - text: string; - timestamp: number; - token: string; - method: (typeof supportedRequestMethods)[number]; -} - -export interface RequestHeaders { - hmac: string; - timestamp: number; - tokenHash: string; -} - -export interface CalculateRequestHeadersOptions { - url: string; - text: string; - token: string; - method: (typeof supportedRequestMethods)[number]; -} - -export interface VerifyResponseOptions extends CalculateRequestHeadersOptions { - hmac: string; - url: string; - text: string; - timestamp: number; - method: (typeof supportedRequestMethods)[number]; - statusCode?: number; -} - -export interface VerifyResponseInfo { - isValid: boolean; - expectedHmac: string; - signatureSubject: string; - isInResponseValidityWindow: boolean; - verificationTime: number; -} - export interface AuthenticateOptions { username: string; password: string; diff --git a/modules/sdk-api/tsconfig.json b/modules/sdk-api/tsconfig.json index 83d65cec0a..8555fdbcb7 100644 --- a/modules/sdk-api/tsconfig.json +++ b/modules/sdk-api/tsconfig.json @@ -12,6 +12,9 @@ { "path": "../sdk-core" }, + { + "path": "../sdk-hmac" + }, { "path": "../utxo-lib" }, diff --git a/modules/sdk-hmac/.eslintignore b/modules/sdk-hmac/.eslintignore new file mode 100644 index 0000000000..849ddff3b7 --- /dev/null +++ b/modules/sdk-hmac/.eslintignore @@ -0,0 +1 @@ +dist/ diff --git a/modules/sdk-hmac/.gitignore b/modules/sdk-hmac/.gitignore new file mode 100644 index 0000000000..67ccce4c64 --- /dev/null +++ b/modules/sdk-hmac/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.idea/ +dist/ diff --git a/modules/sdk-hmac/.mocharc.yml b/modules/sdk-hmac/.mocharc.yml new file mode 100644 index 0000000000..b2cacd8968 --- /dev/null +++ b/modules/sdk-hmac/.mocharc.yml @@ -0,0 +1,8 @@ +require: 'ts-node/register' +timeout: '60000' +reporter: 'min' +reporter-option: + - 'cdn=true' + - 'json=false' +exit: true +spec: ['test/**/*.ts'] diff --git a/modules/sdk-hmac/.npmignore b/modules/sdk-hmac/.npmignore new file mode 100644 index 0000000000..7df125fca2 --- /dev/null +++ b/modules/sdk-hmac/.npmignore @@ -0,0 +1,12 @@ +!dist/ +.idea/ +.prettierrc.yml +tsconfig.json +src/ +test/ +scripts/ +.nyc_output +CODEOWNERS +node_modules/ +.prettierignore +.mocharc.js diff --git a/modules/sdk-hmac/.prettierignore b/modules/sdk-hmac/.prettierignore new file mode 100644 index 0000000000..3a11d6af29 --- /dev/null +++ b/modules/sdk-hmac/.prettierignore @@ -0,0 +1,2 @@ +.nyc_output/ +dist/ diff --git a/modules/sdk-hmac/.prettierrc.yml b/modules/sdk-hmac/.prettierrc.yml new file mode 100644 index 0000000000..7c3d8dd32a --- /dev/null +++ b/modules/sdk-hmac/.prettierrc.yml @@ -0,0 +1,3 @@ +printWidth: 120 +singleQuote: true +trailingComma: 'es5' diff --git a/modules/sdk-hmac/CHANGELOG.md b/modules/sdk-hmac/CHANGELOG.md new file mode 100644 index 0000000000..e4d87c4d45 --- /dev/null +++ b/modules/sdk-hmac/CHANGELOG.md @@ -0,0 +1,4 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. diff --git a/modules/sdk-hmac/README.md b/modules/sdk-hmac/README.md new file mode 100644 index 0000000000..bda70d4cfc --- /dev/null +++ b/modules/sdk-hmac/README.md @@ -0,0 +1,50 @@ +# @bitgo/sdk-sdk-hmac + +Isolated module for performing hash-based message authentication (HMAC) on API requests. + +## Installation + +```shell +npm i @bitgo/sdk-hmac +``` + +```javascript +import { calculateRequestHeaders, verifyResponse } from '@bitgo/sdk-hmac'; + +const bearerToken = 'v2x123...'; +const url = '/api/v2/wallets'; + +const { hmac, timestamp, tokenHash } = calculateRequestHeaders({ + url, + token: bearerToken, + timestamp: new Date().valueOf().toString(), + // if making a POST/PUT request with a body, pass as text + // text: JSON.stringify(request.body) + // optional, can pass 2 or 3 for auth-version + // authVersion: 3 +}); + +const response = await fetch(url, { + method: 'GET', + headers: { + authorization: `Bearer ${tokenHash}`, + hmac, + 'bitgo-auth-version': '2.0', + 'auth-timestamp': timestamp, + }, +}); + +const verifiedResponse = verifyResponse({ + url, + hmac: response.headers.get('hmac'), + statusCode: response.status, + text: response.text, + timestamp: response.headers.get('timestamp'), + token: bearerToken, + method: 'get', +}); + +if (!verifiedResponse.isValid) { + throw new Error('dont trust this response, possible MITM attack'); +} +``` diff --git a/modules/sdk-hmac/package.json b/modules/sdk-hmac/package.json new file mode 100644 index 0000000000..ff7e8d46bd --- /dev/null +++ b/modules/sdk-hmac/package.json @@ -0,0 +1,49 @@ +{ + "name": "@bitgo/sdk-hmac", + "version": "1.0.0", + "description": "HMAC module for the BitGo SDK", + "main": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "scripts": { + "build": "yarn tsc --build --incremental --verbose .", + "fmt": "prettier --write .", + "check-fmt": "prettier --check .", + "clean": "rm -r ./dist", + "lint": "eslint --quiet .", + "test": "npm run coverage", + "coverage": "nyc -- npm run unit-test", + "unit-test": "mocha", + "prepare": "npm run build" + }, + "dependencies": { + "@bitgo/sjcl": "^1.0.1" + }, + "devDependencies": { + "chai": "^4.3.6", + "sinon": "^6.3.5" + }, + "author": "BitGo SDK Team ", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/BitGo/BitGoJS.git", + "directory": "modules/sdk-hmac" + }, + "files": [ + "dist/src" + ], + "lint-staged": { + "*.{js,ts}": [ + "yarn prettier --write", + "yarn eslint --fix" + ] + }, + "publishConfig": { + "access": "public" + }, + "nyc": { + "extension": [ + ".ts" + ] + } +} diff --git a/modules/sdk-hmac/src/hmac.ts b/modules/sdk-hmac/src/hmac.ts new file mode 100644 index 0000000000..56839f7184 --- /dev/null +++ b/modules/sdk-hmac/src/hmac.ts @@ -0,0 +1,131 @@ +import { createHmac } from 'crypto'; +import * as urlLib from 'url'; +import * as sjcl from '@bitgo/sjcl'; +import { + CalculateHmacSubjectOptions, + CalculateRequestHeadersOptions, + CalculateRequestHmacOptions, + RequestHeaders, + VerifyResponseInfo, + VerifyResponseOptions, +} from './types'; + +/** + * Calculate the HMAC for the given key and message + * @param key {String} - the key to use for the HMAC + * @param message {String} - the actual message to HMAC + * @returns {*} - the result of the HMAC operation + */ +export function calculateHMAC(key: string, message: string): string { + return createHmac('sha256', key).update(message).digest('hex'); +} + +/** + * Calculate the subject string that is to be HMAC'ed for a HTTP request or response + * @param urlPath request url, including query params + * @param text request body text + * @param timestamp request timestamp from `Date.now()` + * @param statusCode Only set for HTTP responses, leave blank for requests + * @param method request method + * @returns {string} + */ +export function calculateHMACSubject({ + urlPath, + text, + timestamp, + statusCode, + method, + authVersion, +}: CalculateHmacSubjectOptions): string { + const urlDetails = urlLib.parse(urlPath); + const queryPath = urlDetails.query && urlDetails.query.length > 0 ? urlDetails.path : urlDetails.pathname; + if (statusCode !== undefined && isFinite(statusCode) && Number.isInteger(statusCode)) { + if (authVersion === 3) { + return [method.toUpperCase(), timestamp, queryPath, statusCode, text].join('|'); + } + return [timestamp, queryPath, statusCode, text].join('|'); + } + if (authVersion === 3) { + return [method.toUpperCase(), timestamp, '3.0', queryPath, text].join('|'); + } + return [timestamp, queryPath, text].join('|'); +} + +/** + * Calculate the HMAC for an HTTP request + */ +export function calculateRequestHMAC({ + url: urlPath, + text, + timestamp, + token, + method, + authVersion, +}: CalculateRequestHmacOptions): string { + const signatureSubject = calculateHMACSubject({ urlPath, text, timestamp, method, authVersion }); + + // calculate the HMAC + return calculateHMAC(token, signatureSubject); +} + +/** + * Calculate request headers with HMAC + */ +export function calculateRequestHeaders({ + url, + text, + token, + method, + authVersion, +}: CalculateRequestHeadersOptions): RequestHeaders { + const timestamp = Date.now(); + const hmac = calculateRequestHMAC({ url, text, timestamp, token, method, authVersion }); + + // calculate the SHA256 hash of the token + const hashDigest = sjcl.hash.sha256.hash(token); + const tokenHash = sjcl.codec.hex.fromBits(hashDigest); + return { + hmac, + timestamp, + tokenHash, + }; +} + +/** + * Verify the HMAC for an HTTP response + */ +export function verifyResponse({ + url: urlPath, + statusCode, + text, + timestamp, + token, + hmac, + method, + authVersion, +}: VerifyResponseOptions): VerifyResponseInfo { + const signatureSubject = calculateHMACSubject({ + urlPath, + text, + timestamp, + statusCode, + method, + authVersion, + }); + + // calculate the HMAC + const expectedHmac = calculateHMAC(token, signatureSubject); + + // determine if the response is still within the validity window (5 minute window) + const now = Date.now(); + const isInResponseValidityWindow = timestamp >= now - 1000 * 60 * 5 && timestamp <= now; + + // verify the HMAC and timestamp + return { + isValid: expectedHmac === hmac, + expectedHmac, + signatureSubject, + isInResponseValidityWindow, + verificationTime: now, + }; +} diff --git a/modules/sdk-hmac/src/index.ts b/modules/sdk-hmac/src/index.ts new file mode 100644 index 0000000000..eddbbf391b --- /dev/null +++ b/modules/sdk-hmac/src/index.ts @@ -0,0 +1,2 @@ +export * from './hmac'; +export * from './types'; diff --git a/modules/sdk-hmac/src/types.ts b/modules/sdk-hmac/src/types.ts new file mode 100644 index 0000000000..3952b03e38 --- /dev/null +++ b/modules/sdk-hmac/src/types.ts @@ -0,0 +1,53 @@ +export const supportedRequestMethods = ['get', 'post', 'put', 'del', 'patch', 'options'] as const; + +export type AuthVersion = 2 | 3; + +export interface CalculateHmacSubjectOptions { + urlPath: string; + text: string; + timestamp: number; + method: (typeof supportedRequestMethods)[number]; + statusCode?: number; + authVersion: AuthVersion; +} + +export interface CalculateRequestHmacOptions { + url: string; + text: string; + timestamp: number; + token: string; + method: (typeof supportedRequestMethods)[number]; + authVersion: AuthVersion; +} + +export interface CalculateRequestHeadersOptions { + url: string; + text: string; + token: string; + method: (typeof supportedRequestMethods)[number]; + authVersion: AuthVersion; +} + +export interface RequestHeaders { + hmac: string; + timestamp: number; + tokenHash: string; +} + +export interface VerifyResponseOptions extends CalculateRequestHeadersOptions { + hmac: string; + url: string; + text: string; + timestamp: number; + method: (typeof supportedRequestMethods)[number]; + statusCode?: number; + authVersion: AuthVersion; +} + +export interface VerifyResponseInfo { + isValid: boolean; + expectedHmac: string; + signatureSubject: string; + isInResponseValidityWindow: boolean; + verificationTime: number; +} diff --git a/modules/sdk-hmac/test/hmac.ts b/modules/sdk-hmac/test/hmac.ts new file mode 100644 index 0000000000..f5e2578382 --- /dev/null +++ b/modules/sdk-hmac/test/hmac.ts @@ -0,0 +1,150 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { + calculateHMAC, + calculateHMACSubject, + calculateRequestHMAC, + calculateRequestHeaders, + verifyResponse, +} from '../src/hmac'; +import * as sjcl from '@bitgo/sjcl'; + +// Mock Date.now for consistent timestamp values +const MOCK_TIMESTAMP = 1672531200000; // Example timestamp (e.g., Jan 1, 2023, 00:00:00 UTC) + +describe('HMAC Utility Functions', () => { + let clock; + + before(() => { + clock = sinon.useFakeTimers(MOCK_TIMESTAMP); + }); + + after(() => { + clock.restore(); + }); + + describe('calculateHMAC', () => { + it('should calculate the correct HMAC for a given key and message', () => { + const key = 'test-key'; + const message = 'test-message'; + const expectedHmac = 'f8c2bb87c17608c9038eab4e92ef2775e42629c939d6fd3390d42f80af6bb712'; + expect(calculateHMAC(key, message)).to.equal(expectedHmac); + }); + }); + + describe('calculateHMACSubject', () => { + it('should calculate the correct subject for a request', () => { + const expectedSubject = 'GET|1672531200000|3.0|/api/test?query=123|body-content'; + expect( + calculateHMACSubject({ + urlPath: '/api/test?query=123', + text: 'body-content', + timestamp: MOCK_TIMESTAMP, + method: 'get', + authVersion: 3, + }) + ).to.equal(expectedSubject); + }); + + it('should include statusCode for a response', () => { + const expectedSubject = 'GET|1672531200000|/api/test|200|response-body'; + expect( + calculateHMACSubject({ + urlPath: '/api/test', + text: 'response-body', + timestamp: MOCK_TIMESTAMP, + statusCode: 200, + method: 'get', + authVersion: 3, + }) + ).to.equal(expectedSubject); + }); + }); + + describe('calculateRequestHMAC', () => { + it('should calculate the correct HMAC for a request', () => { + const expectedHmac = '56b7c2bb722ebfa55600a0201af42ad5cd926340d9df5735005d91db452386d1'; + expect( + calculateRequestHMAC({ + url: '/api/test', + text: 'request-body', + timestamp: MOCK_TIMESTAMP, + token: 'test-token', + method: 'post', + authVersion: 3, + }) + ).to.equal(expectedHmac); + }); + }); + + describe('calculateRequestHeaders', () => { + it('should calculate the correct headers with HMAC', () => { + const headers = calculateRequestHeaders({ + url: '/api/test', + text: 'request-body', + token: 'test-token', + method: 'post', + authVersion: 3, + }); + const hashDigest = sjcl.hash.sha256.hash('test-token'); + const tokenHash = sjcl.codec.hex.fromBits(hashDigest); + + expect(headers).to.include({ + hmac: headers.hmac, // Verify hmac exists + timestamp: MOCK_TIMESTAMP, + tokenHash, + }); + }); + }); + + describe('verifyResponse', () => { + it('should verify the HMAC and timestamp validity', () => { + const result = verifyResponse({ + url: '/api/test', + statusCode: 200, + text: 'response-body', + timestamp: MOCK_TIMESTAMP, + token: 'test-token', + hmac: 'a16c08b1fa8bff1e2e58d1831855e1745361f78bd6eb6e18b5b7ee17ae0a3bb7', + method: 'post', + authVersion: 3, + }); + + expect(result).to.include({ + isValid: true, + expectedHmac: 'a16c08b1fa8bff1e2e58d1831855e1745361f78bd6eb6e18b5b7ee17ae0a3bb7', + isInResponseValidityWindow: true, + }); + }); + + it('should return invalid if HMAC does not match', () => { + const result = verifyResponse({ + url: '/api/test', + statusCode: 200, + text: 'response-body', + timestamp: MOCK_TIMESTAMP, + token: 'wrong-token', + hmac: 'invalid-hmac', + method: 'post', + authVersion: 3, + }); + + expect(result.isValid).to.be.false; + }); + + it('should return invalid if timestamp is outside the validity window', () => { + const result = verifyResponse({ + url: '/api/test', + statusCode: 200, + text: 'response-body', + timestamp: MOCK_TIMESTAMP - 1000 * 60 * 10, // 10 minutes in the past + token: 'test-token', + hmac: '8f6a2d183e4c4f2bd2023202486e1651292c84573a31b3829d394f1763a6ec6c', + method: 'post', + authVersion: 3, + }); + + expect(result.isInResponseValidityWindow).to.be.false; + }); + }); +}); diff --git a/modules/sdk-hmac/tsconfig.json b/modules/sdk-hmac/tsconfig.json new file mode 100644 index 0000000000..9a4e1ca6ba --- /dev/null +++ b/modules/sdk-hmac/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./", + "strictPropertyInitialization": false, + "esModuleInterop": true, + "typeRoots": ["../../types", "./node_modules/@types", "../../node_modules/@types"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/tsconfig.packages.json b/tsconfig.packages.json index ae168fd631..46b591d78e 100644 --- a/tsconfig.packages.json +++ b/tsconfig.packages.json @@ -199,6 +199,9 @@ { "path": "./modules/sdk-core" }, + { + "path": "./modules/sdk-hmac" + }, { "path": "./modules/sdk-lib-mpc" },