diff --git a/modules/sdk-api/src/api.ts b/modules/sdk-api/src/api.ts index 0693f1389a..c605b378e8 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 { AuthVersion, VerifyResponseOptions } from './types'; +import { AuthVersion, VerifyResponseInfo, VerifyResponseOptions } from './types'; import { BitGoAPI } from './bitgoAPI'; const debug = Debug('bitgo:api'); @@ -214,44 +214,23 @@ export function setRequestQueryString(req: superagent.SuperAgentRequest): void { } /** - * Verify that the response received from the server is signed correctly. - * Right now, it is very permissive with the timestamp variance. + * Validate a completed verification response and throw a descriptive `ApiResponseError` if it + * indicates the response is invalid or outside the acceptable time window. */ -export function verifyResponse( +function assertVerificationResponse( bitgo: BitGoAPI, token: string | undefined, - method: VerifyResponseOptions['method'], req: superagent.SuperAgentRequest, response: superagent.Response, - authVersion: AuthVersion -): superagent.Response { - // we can't verify the response if we're not authenticated - if (!req.isV2Authenticated || !req.authenticationToken) { - return response; - } - - const verificationResponse = bitgo.verifyResponse({ - url: req.url, - hmac: response.header.hmac, - statusCode: response.status, - text: response.text, - timestamp: response.header.timestamp, - token: req.authenticationToken, - method, - authVersion, - }); - + verificationResponse: VerifyResponseInfo +): void { if (!verificationResponse.isValid) { - // calculate the HMAC - const receivedHmac = response.header.hmac; - const expectedHmac = verificationResponse.expectedHmac; - const signatureSubject = verificationResponse.signatureSubject; // Log only the first 10 characters of the token to ensure the full token isn't logged. const partialBitgoToken = token ? token.substring(0, 10) : ''; const errorDetails = { - expectedHmac, - receivedHmac, - hmacInput: signatureSubject, + expectedHmac: verificationResponse.expectedHmac, + receivedHmac: response.header.hmac, + hmacInput: verificationResponse.signatureSubject, requestToken: req.authenticationToken, bitgoToken: partialBitgoToken, }; @@ -271,5 +250,62 @@ export function verifyResponse( errorDetails ); } +} + +/** + * Verify that the response received from the server is signed correctly. + * Right now, it is very permissive with the timestamp variance. + */ +export function verifyResponse( + bitgo: BitGoAPI, + token: string | undefined, + method: VerifyResponseOptions['method'], + req: superagent.SuperAgentRequest, + response: superagent.Response, + authVersion: AuthVersion +): superagent.Response { + if (!req.isV2Authenticated || !req.authenticationToken) { + return response; + } + + const verificationResponse = bitgo.verifyResponse({ + url: req.url, + hmac: response.header.hmac, + statusCode: response.status, + text: response.text, + timestamp: response.header.timestamp, + token: req.authenticationToken, + method, + authVersion, + }); + + assertVerificationResponse(bitgo, token, req, response, verificationResponse); + return response; +} + +export async function verifyResponseAsync( + bitgo: BitGoAPI, + token: string | undefined, + method: VerifyResponseOptions['method'], + req: superagent.SuperAgentRequest, + response: superagent.Response, + authVersion: AuthVersion +): Promise { + if (!req.isV2Authenticated || !req.authenticationToken) { + return response; + } + + const verificationResponse = await bitgo.verifyResponseAsync({ + url: req.url, + hmac: response.header.hmac, + statusCode: response.status, + text: response.text, + timestamp: response.header.timestamp, + token: req.authenticationToken, + method, + authVersion, + }); + + assertVerificationResponse(bitgo, token, req, response, verificationResponse); return response; } diff --git a/modules/sdk-api/src/bitgoAPI.ts b/modules/sdk-api/src/bitgoAPI.ts index 33205f8b1e..e55ee2abee 100644 --- a/modules/sdk-api/src/bitgoAPI.ts +++ b/modules/sdk-api/src/bitgoAPI.ts @@ -23,6 +23,7 @@ import { sanitizeLegacyPath, } from '@bitgo/sdk-core'; import * as sdkHmac from '@bitgo/sdk-hmac'; +import { DefaultHmacAuthStrategy, type IHmacAuthStrategy } from '@bitgo/sdk-hmac'; import * as utxolib from '@bitgo/utxo-lib'; import { bip32, ECPairInterface } from '@bitgo/utxo-lib'; import * as bitcoinMessage from 'bitcoinjs-message'; @@ -37,7 +38,7 @@ import { serializeRequestData, setRequestQueryString, toBitgoRequest, - verifyResponse, + verifyResponseAsync, } from './api'; import { decrypt, encrypt } from './encrypt'; import { verifyAddress } from './v1/verifyAddress'; @@ -134,6 +135,7 @@ export class BitGoAPI implements BitGoBase { private _customProxyAgent?: Agent; private _requestIdPrefix?: string; private getAdditionalHeadersCb?: AdditionalHeadersCallback; + protected _hmacAuthStrategy: IHmacAuthStrategy; constructor(params: BitGoAPIOptions = {}) { this.getAdditionalHeadersCb = params.getAdditionalHeadersCb; @@ -309,6 +311,7 @@ export class BitGoAPI implements BitGoBase { } this._customProxyAgent = params.customProxyAgent; + this._hmacAuthStrategy = params.hmacAuthStrategy ?? new DefaultHmacAuthStrategy(); // Only fetch constants from constructor if clientConstants was not provided if (!clientConstants) { @@ -423,9 +426,12 @@ export class BitGoAPI implements BitGoBase { // Set the request timeout to just above 5 minutes by default req.timeout((process.env.BITGO_TIMEOUT as any) * 1000 || 305 * 1000); - // if there is no token, and we're not logged in, the request cannot be v2 authenticated + // The strategy may have its own signing material (e.g. a CryptoKey + // restored from IndexedDB) independent of this._token. + const strategyAuthenticated = this._hmacAuthStrategy.isAuthenticated?.() ?? false; + req.isV2Authenticated = true; - req.authenticationToken = this._token; + req.authenticationToken = this._token ?? (strategyAuthenticated ? 'strategy-authenticated' : undefined); // some of the older tokens appear to be only 40 characters long if ((this._token && this._token.length !== 67 && this._token.indexOf('v2x') !== 0) || req.forceV1Auth) { // use the old method @@ -439,51 +445,66 @@ export class BitGoAPI implements BitGoBase { req.set('BitGo-Auth-Version', this._authVersion === 3 ? '3.0' : '2.0'); const data = serializeRequestData(req); - if (this._token) { - setRequestQueryString(req); - - const requestProperties = this.calculateRequestHeaders({ - url: req.url, - token: this._token, - method, - text: data || '', - authVersion: this._authVersion, - }); - req.set('Auth-Timestamp', requestProperties.timestamp.toString()); - - // we're not sending the actual token, but only its hash - req.set('Authorization', 'Bearer ' + requestProperties.tokenHash); - debug('sending v2 %s request to %s with token %s', method, url, this._token?.substr(0, 8)); - // set the HMAC - req.set('HMAC', requestProperties.hmac); - } + const sendWithHmac = (async () => { + if (this._token || strategyAuthenticated) { + setRequestQueryString(req); + + const requestProperties = await this._hmacAuthStrategy.calculateRequestHeaders({ + url: req.url, + token: this._token ?? '', + method, + text: data || '', + authVersion: this._authVersion, + }); + req.set('Auth-Timestamp', requestProperties.timestamp.toString()); + + req.set('Authorization', 'Bearer ' + requestProperties.tokenHash); + debug( + 'sending v2 %s request to %s with token %s', + method, + url, + this._token?.substr(0, 8) ?? '(strategy-managed)' + ); + + req.set('HMAC', requestProperties.hmac); + } - if (this.getAdditionalHeadersCb) { - const additionalHeaders = this.getAdditionalHeadersCb(method, url, data); - for (const { key, value } of additionalHeaders) { - req.set(key, value); + if (this.getAdditionalHeadersCb) { + const additionalHeaders = this.getAdditionalHeadersCb(method, url, data); + for (const { key, value } of additionalHeaders) { + req.set(key, value); + } } - } - /** - * Verify the response before calling the original onfulfilled handler, - * and make sure onrejected is called if a verification error is encountered - */ - const newOnFulfilled = onfulfilled - ? (response: superagent.Response) => { - // HMAC verification is only allowed to be skipped in certain environments. - // This is checked in the constructor, but checking it again at request time - // will help prevent against tampering of this property after the object is created - if (!this._hmacVerification && !common.Environments[this.getEnv()].hmacVerificationEnforced) { - return onfulfilled(response); + /** + * Verify the response before calling the original onfulfilled handler, + * and make sure onrejected is called if a verification error is encountered + */ + const newOnFulfilled = onfulfilled + ? async (response: superagent.Response) => { + // HMAC verification is only allowed to be skipped in certain environments. + // This is checked in the constructor, but checking it again at request time + // will help prevent against tampering of this property after the object is created + if (!this._hmacVerification && !common.Environments[this.getEnv()].hmacVerificationEnforced) { + return onfulfilled(response); + } + + const verifiedResponse = await verifyResponseAsync( + this, + this._token, + method, + req, + response, + this._authVersion + ); + return onfulfilled(verifiedResponse); } + : null; + return originalThen(newOnFulfilled); + })(); - const verifiedResponse = verifyResponse(this, this._token, method, req, response, this._authVersion); - return onfulfilled(verifiedResponse); - } - : null; - return originalThen(newOnFulfilled).catch(onrejected); + return sendWithHmac.catch(onrejected); }; return toBitgoRequest(req); } @@ -545,12 +566,21 @@ export class BitGoAPI implements BitGoBase { } /** - * Verify the HMAC for an HTTP response + * Verify the HMAC for an HTTP response (synchronous, uses sdk-hmac directly). + * Kept for backward compatibility with external callers. */ verifyResponse(params: VerifyResponseOptions): VerifyResponseInfo { return sdkHmac.verifyResponse({ ...params, authVersion: this._authVersion }); } + /** + * Verify the HMAC for an HTTP response via the configured strategy (async). + * Used internally by the request pipeline. + */ + verifyResponseAsync(params: VerifyResponseOptions): Promise { + return this._hmacAuthStrategy.verifyResponse({ ...params, authVersion: this._authVersion }); + } + /** * Fetch useful constant values from the BitGo server. * These values do change infrequently, so they need to be fetched, @@ -772,7 +802,7 @@ export class BitGoAPI implements BitGoBase { * Process the username, password and otp into an object containing the username and hashed password, ready to * send to bitgo for authentication. */ - preprocessAuthenticationParams({ + async preprocessAuthenticationParams({ username, password, otp, @@ -782,7 +812,7 @@ export class BitGoAPI implements BitGoBase { forReset2FA, initialHash, fingerprintHash, - }: AuthenticateOptions): ProcessedAuthenticationOptions { + }: AuthenticateOptions): Promise { if (!_.isString(username)) { throw new Error('expected string username'); } @@ -793,7 +823,7 @@ export class BitGoAPI implements BitGoBase { const lowerName = username.toLowerCase(); // Calculate the password HMAC so we don't send clear-text passwords - const hmacPassword = this.calculateHMAC(lowerName, password); + const hmacPassword = await this._hmacAuthStrategy.calculateHMAC(lowerName, password); const authParams: ProcessedAuthenticationOptions = { email: lowerName, @@ -944,7 +974,7 @@ export class BitGoAPI implements BitGoBase { } const forceV1Auth = !!params.forceV1Auth; - const authParams = this.preprocessAuthenticationParams(params); + const authParams = await this.preprocessAuthenticationParams(params); const password = params.password; if (this._token) { @@ -981,7 +1011,7 @@ export class BitGoAPI implements BitGoBase { this._ecdhXprv = responseDetails.ecdhXprv; // verify the response's authenticity - verifyResponse(this, responseDetails.token, 'post', request, response, this._authVersion); + await verifyResponseAsync(this, responseDetails.token, 'post', request, response, this._authVersion); // add the remaining component for easier access response.body.access_token = this._token; @@ -1111,7 +1141,7 @@ export class BitGoAPI implements BitGoBase { /** */ - verifyPassword(params: VerifyPasswordOptions = {}): Promise { + async verifyPassword(params: VerifyPasswordOptions = {}): Promise { if (!_.isString(params.password)) { throw new Error('missing required string password'); } @@ -1119,7 +1149,7 @@ export class BitGoAPI implements BitGoBase { if (!this._user || !this._user.username) { throw new Error('no current user'); } - const hmacPassword = this.calculateHMAC(this._user.username, params.password); + const hmacPassword = await this._hmacAuthStrategy.calculateHMAC(this._user.username, params.password); return this.post(this.url('/user/verifypassword')).send({ password: hmacPassword }).result('valid'); } @@ -1269,7 +1299,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, this._authVersion); + await verifyResponseAsync(this, this._token, 'post', request, response, this._authVersion); const responseDetails = this.handleTokenIssuance(response.body); response.body.token = responseDetails.token; @@ -1924,12 +1954,17 @@ export class BitGoAPI implements BitGoBase { const v1KeychainUpdatePWResult = await this.keychains().updatePassword(updateKeychainPasswordParams); const v2Keychains = await this.coin(coin).keychains().updatePassword(updateKeychainPasswordParams); + const [hmacOldPassword, hmacNewPassword] = await Promise.all([ + this._hmacAuthStrategy.calculateHMAC(user.username, oldPassword), + this._hmacAuthStrategy.calculateHMAC(user.username, newPassword), + ]); + const updatePasswordParams = { keychains: v1KeychainUpdatePWResult.keychains, v2_keychains: v2Keychains, version: v1KeychainUpdatePWResult.version, - oldPassword: this.calculateHMAC(user.username, oldPassword), - password: this.calculateHMAC(user.username, newPassword), + oldPassword: hmacOldPassword, + password: hmacNewPassword, }; // Calculate payload size in KB diff --git a/modules/sdk-api/src/types.ts b/modules/sdk-api/src/types.ts index b3d878e7be..c5efb02aa3 100644 --- a/modules/sdk-api/src/types.ts +++ b/modules/sdk-api/src/types.ts @@ -17,14 +17,19 @@ export { CalculateHmacSubjectOptions, CalculateRequestHeadersOptions, CalculateRequestHmacOptions, + IHmacAuthStrategy, RequestHeaders, supportedRequestMethods, VerifyResponseInfo, VerifyResponseOptions, } from '@bitgo/sdk-hmac'; + +import type { IHmacAuthStrategy } from '@bitgo/sdk-hmac'; + export interface BitGoAPIOptions { accessToken?: string; authVersion?: 2 | 3; + hmacAuthStrategy?: IHmacAuthStrategy; clientConstants?: | Record | { diff --git a/modules/sdk-api/test/unit/hmacStrategy.ts b/modules/sdk-api/test/unit/hmacStrategy.ts new file mode 100644 index 0000000000..8a861a60dd --- /dev/null +++ b/modules/sdk-api/test/unit/hmacStrategy.ts @@ -0,0 +1,175 @@ +import 'should'; +import nock from 'nock'; +import { BitGoAPI } from '../../src/bitgoAPI'; +import type { + IHmacAuthStrategy, + CalculateRequestHeadersOptions, + RequestHeaders, + VerifyResponseOptions, + VerifyResponseInfo, +} from '@bitgo/sdk-hmac'; +import assert from 'node:assert'; + +const TEST_TOKEN = 'v2x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdefab'; +const TEST_URI = 'https://app.example.local'; + +/** + * Mock strategy that records calls and returns predictable values. + */ +class MockHmacAuthStrategy implements IHmacAuthStrategy { + public calculateRequestHeadersCalls: CalculateRequestHeadersOptions[] = []; + public verifyResponseCalls: VerifyResponseOptions[] = []; + public calculateHMACCalls: Array<{ key: string; message: string }> = []; + + async calculateRequestHeaders(params: CalculateRequestHeadersOptions): Promise { + this.calculateRequestHeadersCalls.push(params); + return { + hmac: 'mock-hmac-value', + timestamp: 1672531200000, + tokenHash: 'mock-token-hash', + }; + } + + async verifyResponse(params: VerifyResponseOptions): Promise { + this.verifyResponseCalls.push(params); + return { + isValid: true, + expectedHmac: 'mock-hmac-value', + signatureSubject: 'mock-subject', + isInResponseValidityWindow: true, + verificationTime: Date.now(), + }; + } + + async calculateHMAC(key: string, message: string): Promise { + this.calculateHMACCalls.push({ key, message }); + return 'mock-hmac-password'; + } +} + +describe('BitGoAPI HMAC Strategy Injection', function () { + afterEach(function () { + nock.cleanAll(); + }); + + describe('constructor', function () { + it('should accept a custom hmacAuthStrategy', function () { + const strategy = new MockHmacAuthStrategy(); + const bitgo = new BitGoAPI({ + env: 'custom', + customRootURI: TEST_URI, + hmacAuthStrategy: strategy, + }); + + bitgo.should.be.ok(); + }); + + it('should default to DefaultHmacAuthStrategy when none is provided', function () { + const bitgo = new BitGoAPI({ + env: 'custom', + customRootURI: TEST_URI, + }); + + bitgo.should.be.ok(); + }); + }); + + describe('request signing via strategy', function () { + it('should use the custom strategy for HMAC header calculation', async function () { + const strategy = new MockHmacAuthStrategy(); + const bitgo = new BitGoAPI({ + env: 'custom', + customRootURI: TEST_URI, + accessToken: TEST_TOKEN, + hmacAuthStrategy: strategy, + }); + + const scope = nock(TEST_URI) + .get('/api/v2/wallet') + .matchHeader('HMAC', 'mock-hmac-value') + .matchHeader('Authorization', 'Bearer mock-token-hash') + .matchHeader('Auth-Timestamp', '1672531200000') + .reply( + 200, + { wallets: [] }, + { + hmac: 'response-hmac', + timestamp: Date.now().toString(), + } + ); + + await bitgo.get(bitgo.url('/wallet', 2)).result(); + + strategy.calculateRequestHeadersCalls.length.should.equal(1); + const call = strategy.calculateRequestHeadersCalls[0]; + call.token.should.equal(TEST_TOKEN); + call.method.should.equal('get'); + + scope.isDone().should.be.true(); + }); + + it('should use the custom strategy for response verification', async function () { + const strategy = new MockHmacAuthStrategy(); + const bitgo = new BitGoAPI({ + env: 'custom', + customRootURI: TEST_URI, + accessToken: TEST_TOKEN, + hmacAuthStrategy: strategy, + }); + + nock(TEST_URI).get('/api/v2/wallet').reply( + 200, + { wallets: [] }, + { + hmac: 'server-response-hmac', + timestamp: Date.now().toString(), + } + ); + + await bitgo.get(bitgo.url('/wallet', 2)).result(); + + strategy.verifyResponseCalls.length.should.equal(1); + const call = strategy.verifyResponseCalls[0]; + call.hmac.should.equal('server-response-hmac'); + assert(call.statusCode, 'statusCode is required'); + call.statusCode.should.equal(200); + }); + }); + + describe('password HMAC via strategy', function () { + it('should use the custom strategy for password hashing in preprocessAuthenticationParams', async function () { + const strategy = new MockHmacAuthStrategy(); + const bitgo = new BitGoAPI({ + env: 'custom', + customRootURI: TEST_URI, + hmacAuthStrategy: strategy, + }); + + nock(TEST_URI) + .post('/api/auth/v1/session') + .reply( + 200, + { access_token: TEST_TOKEN, user: { username: 'test@test.com' } }, + { + hmac: 'resp-hmac', + timestamp: Date.now().toString(), + } + ); + + try { + await bitgo.authenticate({ + username: 'test@test.com', + password: 'mypassword', + }); + } catch { + // Authentication may fail for various reasons in this test context, + // but we only care that the strategy was called for password HMAC. + } + + strategy.calculateHMACCalls.length.should.be.greaterThan(0); + const hmacCall = strategy.calculateHMACCalls[0]; + hmacCall.key.should.equal('test@test.com'); + hmacCall.message.should.equal('mypassword'); + }); + }); +}); diff --git a/modules/sdk-hmac/package.json b/modules/sdk-hmac/package.json index 724cfa1dcf..7db9009800 100644 --- a/modules/sdk-hmac/package.json +++ b/modules/sdk-hmac/package.json @@ -4,6 +4,23 @@ "description": "HMAC module for the BitGo SDK", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "default": "./dist/src/index.js" + }, + "./browser": { + "types": "./dist/src/browser.d.ts", + "default": "./dist/src/browser.js" + } + }, + "typesVersions": { + "*": { + "browser": [ + "./dist/src/browser.d.ts" + ] + } + }, "scripts": { "build": "yarn tsc --build --incremental --verbose .", "fmt": "prettier --write .", diff --git a/modules/sdk-hmac/src/browser.ts b/modules/sdk-hmac/src/browser.ts new file mode 100644 index 0000000000..2bfa5a033e --- /dev/null +++ b/modules/sdk-hmac/src/browser.ts @@ -0,0 +1,2 @@ +export * from './index'; +export * from './webCryptoStrategy'; diff --git a/modules/sdk-hmac/src/defaultStrategy.ts b/modules/sdk-hmac/src/defaultStrategy.ts new file mode 100644 index 0000000000..847efbddf5 --- /dev/null +++ b/modules/sdk-hmac/src/defaultStrategy.ts @@ -0,0 +1,26 @@ +import { calculateRequestHeaders, calculateHMAC, verifyResponse } from './hmac'; +import type { + IHmacAuthStrategy, + CalculateRequestHeadersOptions, + RequestHeaders, + VerifyResponseOptions, + VerifyResponseInfo, +} from './types'; + +/** + * Default HMAC auth strategy that wraps the existing synchronous Node.js crypto + * functions. This is used when no custom strategy is provided to BitGoAPI. + */ +export class DefaultHmacAuthStrategy implements IHmacAuthStrategy { + async calculateRequestHeaders(params: CalculateRequestHeadersOptions): Promise { + return calculateRequestHeaders(params); + } + + async verifyResponse(params: VerifyResponseOptions): Promise { + return verifyResponse(params); + } + + async calculateHMAC(key: string, message: string): Promise { + return calculateHMAC(key, message); + } +} diff --git a/modules/sdk-hmac/src/index.ts b/modules/sdk-hmac/src/index.ts index 1e9631cf30..a066381594 100644 --- a/modules/sdk-hmac/src/index.ts +++ b/modules/sdk-hmac/src/index.ts @@ -2,3 +2,4 @@ export * from './hmac'; export * from './hmacv4'; export * from './util'; export * from './types'; +export * from './defaultStrategy'; diff --git a/modules/sdk-hmac/src/types.ts b/modules/sdk-hmac/src/types.ts index 1cbb57f799..3349afd604 100644 --- a/modules/sdk-hmac/src/types.ts +++ b/modules/sdk-hmac/src/types.ts @@ -108,3 +108,52 @@ export interface VerifyV4ResponseInfo { isInResponseValidityWindow: boolean; verificationTime: number; } + +/** + * Strategy interface for pluggable HMAC authentication. + * + * Implementations handle request signing, response verification, and general HMAC + * computation. All methods are async to support browser WebCrypto (crypto.subtle). + * + * The `token` field in params is provided for implementations that use it directly + * (e.g. DefaultHmacAuthStrategy). Implementations that manage their own key material + * (e.g. WebCryptoHmacStrategy with a CryptoKey) may ignore it. + */ +export interface IHmacAuthStrategy { + calculateRequestHeaders(params: CalculateRequestHeadersOptions): Promise; + + verifyResponse(params: VerifyResponseOptions): Promise; + + calculateHMAC(key: string, message: string): Promise; + + /** + * Optional. Returns true if the strategy has its own signing material + * (e.g. a CryptoKey restored from IndexedDB) and can sign requests + * independently of BitGoAPI._token. + * + * When this returns true, BitGoAPI.requestPatch will delegate signing + * to the strategy even if no raw token string is available. + */ + isAuthenticated?(): boolean; +} + +/** + * Opaque signing material derived from a bearer token. + * Stored in IndexedDB (CryptoKey is preserved via structured clone). + * The raw token is never persisted — only the non-extractable key and the hash. + */ +export type CryptoSigning = { + cryptoKey: CryptoKey; + tokenHash: string; +}; + +/** + * Pluggable persistence interface for {@link CryptoSigning} material. + * Allows different storage backends (IndexedDB, in-memory for tests) for + * persisting signing keys across page refreshes or app restarts. + */ +export interface ITokenStore { + save(signing: CryptoSigning): Promise; + load(): Promise; + remove(): Promise; +} diff --git a/modules/sdk-hmac/src/webCryptoStrategy.ts b/modules/sdk-hmac/src/webCryptoStrategy.ts new file mode 100644 index 0000000000..9fdcf3bda0 --- /dev/null +++ b/modules/sdk-hmac/src/webCryptoStrategy.ts @@ -0,0 +1,364 @@ +/** + * Browser-native HMAC auth strategy using the Web Crypto API. + * + * This file has ZERO Node.js imports (no `crypto`, `url`, or `Buffer`). + * All browser API usage (crypto.subtle, indexedDB) is inside method bodies, + * so the module can be safely imported in any environment -- it only requires + * browser globals when methods are actually called. + */ +import type { + AuthVersion, + CryptoSigning, + IHmacAuthStrategy, + ITokenStore, + CalculateRequestHeadersOptions, + RequestHeaders, + VerifyResponseOptions, + VerifyResponseInfo, +} from './types'; + +function arrayBufToHex(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + const hexParts: string[] = new Array(bytes.length); + for (let i = 0; i < bytes.length; i++) { + hexParts[i] = bytes[i].toString(16).padStart(2, '0'); + } + return hexParts.join(''); +} + +/** + * Extract the pathname + search from a URL string, using the browser-native URL API. + * Equivalent to what `url.parse(urlPath)` does in Node.js for HMAC subject construction. + */ +function extractQueryPath(urlPath: string): string { + try { + const url = new URL(urlPath); + return url.search.length > 0 ? url.pathname + url.search : url.pathname; + } catch { + try { + const url = new URL(urlPath, 'http://localhost'); + return url.search.length > 0 ? url.pathname + url.search : url.pathname; + } catch { + return urlPath; + } + } +} + +/** + * Build the HMAC subject string for v2/v3 request or response signing. + * Browser-compatible equivalent of `calculateHMACSubject` from hmac.ts, + * producing identical output for string inputs. + */ +function buildHmacSubject(params: { + urlPath: string; + text: string; + timestamp: number; + method: string; + statusCode?: number; + authVersion: AuthVersion; +}): string { + let method = params.method; + if (method === 'del') { + method = 'delete'; + } + + const queryPath = extractQueryPath(params.urlPath); + + let prefixedText: string; + if (params.statusCode !== undefined && isFinite(params.statusCode) && Number.isInteger(params.statusCode)) { + prefixedText = + params.authVersion === 3 + ? [method.toUpperCase(), params.timestamp, queryPath, params.statusCode].join('|') + : [params.timestamp, queryPath, params.statusCode].join('|'); + } else { + prefixedText = + params.authVersion === 3 + ? [method.toUpperCase(), params.timestamp, '3.0', queryPath].join('|') + : [params.timestamp, queryPath].join('|'); + } + + return [prefixedText, params.text].join('|'); +} + +async function webCryptoHmacSign(key: CryptoKey, data: string): Promise { + const encoded = new TextEncoder().encode(data); + const sig = await crypto.subtle.sign('HMAC', key, encoded); + return arrayBufToHex(sig); +} + +async function webCryptoImportHmacKey(rawKey: string): Promise { + const encoded = new TextEncoder().encode(rawKey); + return crypto.subtle.importKey('raw', encoded, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); +} + +async function webCryptoSha256Hex(data: string): Promise { + const encoded = new TextEncoder().encode(data); + const hash = await crypto.subtle.digest('SHA-256', encoded); + return arrayBufToHex(hash); +} + +// --------------------------------------------------------------------------- +// IndexedDB Token Store +// --------------------------------------------------------------------------- + +const CRYPTO_DB_NAME = 'bitgo-auth'; +const CRYPTO_STORE_NAME = 'crypto-signing'; +const CRYPTO_RECORD_KEY = 'current'; + +function hasIndexedDB(): boolean { + return typeof indexedDB !== 'undefined'; +} + +function openCryptoDb(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(CRYPTO_DB_NAME, 1); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(CRYPTO_STORE_NAME)) { + db.createObjectStore(CRYPTO_STORE_NAME); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +async function persistCryptoSigning(signing: CryptoSigning): Promise { + if (!hasIndexedDB()) return; + const db = await openCryptoDb(); + try { + await new Promise((resolve, reject) => { + const tx = db.transaction(CRYPTO_STORE_NAME, 'readwrite'); + tx.objectStore(CRYPTO_STORE_NAME).put(signing, CRYPTO_RECORD_KEY); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } finally { + db.close(); + } +} + +async function loadCryptoSigning(): Promise { + if (!hasIndexedDB()) return null; + const db = await openCryptoDb(); + try { + return await new Promise((resolve, reject) => { + const tx = db.transaction(CRYPTO_STORE_NAME, 'readonly'); + const request = tx.objectStore(CRYPTO_STORE_NAME).get(CRYPTO_RECORD_KEY); + request.onsuccess = () => resolve(request.result ?? null); + request.onerror = () => reject(request.error); + }); + } finally { + db.close(); + } +} + +async function removeCryptoSigning(): Promise { + if (!hasIndexedDB()) return; + const db = await openCryptoDb(); + try { + await new Promise((resolve, reject) => { + const tx = db.transaction(CRYPTO_STORE_NAME, 'readwrite'); + tx.objectStore(CRYPTO_STORE_NAME).delete(CRYPTO_RECORD_KEY); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } finally { + db.close(); + } +} + +/** + * Persists {@link CryptoSigning} material in the browser's IndexedDB. + * The raw bearer token is never stored — only the non-extractable CryptoKey + * and the SHA-256 token hash are persisted via the structured clone algorithm. + */ +export class IndexedDbTokenStore implements ITokenStore { + async save(signing: CryptoSigning): Promise { + await persistCryptoSigning(signing); + } + + async load(): Promise { + return loadCryptoSigning(); + } + + async remove(): Promise { + await removeCryptoSigning(); + } +} + +// --------------------------------------------------------------------------- +// WebCrypto HMAC Strategy +// --------------------------------------------------------------------------- + +export interface WebCryptoHmacStrategyOptions { + tokenStore?: ITokenStore; + authVersion?: AuthVersion; +} + +/** + * HMAC auth strategy using the browser's Web Crypto API (crypto.subtle). + * + * Usable both as an `IHmacAuthStrategy` for BitGoAPI and as a standalone + * utility for signing/verifying requests made with the browser `fetch()` API. + * + * Token lifecycle: + * - Call `setToken(rawToken)` after authentication to import the token as a + * non-extractable CryptoKey and persist it to the configured ITokenStore. + * - Call `restoreToken()` on page load to recover a previously stored token. + * - Call `clearToken()` on logout. + */ +export class WebCryptoHmacStrategy implements IHmacAuthStrategy { + private cryptoKey: CryptoKey | null = null; + private tokenHashHex: string | null = null; + private tokenStore: ITokenStore; + private authVersion: AuthVersion; + + constructor(options?: WebCryptoHmacStrategyOptions) { + this.tokenStore = options?.tokenStore ?? new IndexedDbTokenStore(); + this.authVersion = options?.authVersion ?? 2; + } + + // --- Token lifecycle --------------------------------------------------- + + /** + * Import a raw bearer token: derives a non-extractable CryptoKey for HMAC + * signing, computes the SHA-256 token hash, and persists the + * {@link CryptoSigning} material (NOT the raw token) to the token store. + */ + async setToken(rawToken: string): Promise { + this.cryptoKey = await webCryptoImportHmacKey(rawToken); + this.tokenHashHex = await webCryptoSha256Hex(rawToken); + await this.tokenStore.save({ cryptoKey: this.cryptoKey, tokenHash: this.tokenHashHex }); + } + + async clearToken(): Promise { + this.cryptoKey = null; + this.tokenHashHex = null; + await this.tokenStore.remove(); + } + + /** + * Attempt to restore signing material from the token store (e.g. IndexedDB). + * The stored {@link CryptoSigning} already contains the non-extractable + * CryptoKey and token hash — no raw token is involved. + * Returns true if signing material was successfully restored. + */ + async restoreToken(): Promise { + const signing = await this.tokenStore.load(); + if (signing) { + this.cryptoKey = signing.cryptoKey; + this.tokenHashHex = signing.tokenHash; + return true; + } + return false; + } + + hasToken(): boolean { + return this.cryptoKey !== null && this.tokenHashHex !== null; + } + + isAuthenticated(): boolean { + return this.hasToken(); + } + + // --- IHmacAuthStrategy implementation ----------------------------------- + + async calculateRequestHeaders(params: CalculateRequestHeadersOptions): Promise { + if (!this.cryptoKey || !this.tokenHashHex) { + throw new Error('No token available. Call setToken() or restoreToken() first.'); + } + const timestamp = Date.now(); + const subject = buildHmacSubject({ + urlPath: params.url, + text: params.text as string, + timestamp, + method: params.method, + authVersion: params.authVersion, + }); + const hmac = await webCryptoHmacSign(this.cryptoKey, subject); + return { hmac, timestamp, tokenHash: this.tokenHashHex }; + } + + async verifyResponse(params: VerifyResponseOptions): Promise { + if (!this.cryptoKey) { + throw new Error('No token available. Call setToken() or restoreToken() first.'); + } + const subject = buildHmacSubject({ + urlPath: params.url, + text: params.text as string, + timestamp: params.timestamp, + method: params.method, + statusCode: params.statusCode, + authVersion: params.authVersion, + }); + + const expectedHmac = await webCryptoHmacSign(this.cryptoKey, subject); + + const now = Date.now(); + const backwardValidityWindow = 1000 * 60 * 5; + const forwardValidityWindow = 1000 * 60; + const isInResponseValidityWindow = + params.timestamp >= now - backwardValidityWindow && params.timestamp <= now + forwardValidityWindow; + + return { + isValid: expectedHmac === params.hmac, + expectedHmac, + signatureSubject: subject as VerifyResponseInfo['signatureSubject'], + isInResponseValidityWindow, + verificationTime: now, + }; + } + + async calculateHMAC(key: string, message: string): Promise { + const cryptoKey = await webCryptoImportHmacKey(key); + return webCryptoHmacSign(cryptoKey, message); + } + + // --- Convenience methods for standalone fetch() usage ------------------- + + /** + * Returns a flat headers dict ready to spread into a `fetch()` init object. + * + * Example: + * ``` + * const headers = await strategy.getAuthHeaders({ url, method: 'GET' }); + * const response = await fetch(url, { headers }); + * ``` + */ + async getAuthHeaders(params: { url: string; method: string; body?: string }): Promise> { + const requestHeaders = await this.calculateRequestHeaders({ + url: params.url, + token: '', + method: params.method as CalculateRequestHeadersOptions['method'], + text: params.body ?? '', + authVersion: this.authVersion, + }); + return { + 'Auth-Timestamp': requestHeaders.timestamp.toString(), + Authorization: 'Bearer ' + requestHeaders.tokenHash, + HMAC: requestHeaders.hmac, + 'BitGo-Auth-Version': this.authVersion === 3 ? '3.0' : '2.0', + }; + } + + /** + * Verify a browser Fetch `Response` object's HMAC. + * + * Clones the response to read the body text without consuming it. + */ + async verifyFetchResponse(params: { url: string; method: string; response: Response }): Promise { + const cloned = params.response.clone(); + const text = await cloned.text(); + return this.verifyResponse({ + url: params.url, + token: '', + method: params.method as VerifyResponseOptions['method'], + text, + hmac: params.response.headers.get('hmac') ?? '', + statusCode: params.response.status, + timestamp: parseInt(params.response.headers.get('timestamp') ?? '0', 10), + authVersion: this.authVersion, + }); + } +} diff --git a/modules/sdk-hmac/test/defaultStrategy.ts b/modules/sdk-hmac/test/defaultStrategy.ts new file mode 100644 index 0000000000..02186104c4 --- /dev/null +++ b/modules/sdk-hmac/test/defaultStrategy.ts @@ -0,0 +1,134 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { DefaultHmacAuthStrategy } from '../src/defaultStrategy'; +import * as hmac from '../src/hmac'; + +const MOCK_TIMESTAMP = 1672531200000; + +describe('DefaultHmacAuthStrategy', () => { + let strategy: DefaultHmacAuthStrategy; + let clock: sinon.SinonFakeTimers; + + before(() => { + clock = sinon.useFakeTimers(MOCK_TIMESTAMP); + }); + + after(() => { + clock.restore(); + }); + + beforeEach(() => { + strategy = new DefaultHmacAuthStrategy(); + }); + + describe('calculateRequestHeaders', () => { + it('should produce the same result as the sync calculateRequestHeaders', async () => { + const params = { + url: 'https://app.bitgo.com/api/v2/wallet', + token: 'v2x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdefab', + method: 'get' as const, + text: '', + authVersion: 2 as const, + }; + + const syncResult = hmac.calculateRequestHeaders(params); + const asyncResult = await strategy.calculateRequestHeaders(params); + + expect(asyncResult.hmac).to.equal(syncResult.hmac); + expect(asyncResult.timestamp).to.equal(syncResult.timestamp); + expect(asyncResult.tokenHash).to.equal(syncResult.tokenHash); + }); + + it('should produce correct headers for v3 auth with a body', async () => { + const params = { + url: 'https://app.bitgo.com/api/v2/wallet/send', + token: 'v2x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdefab', + method: 'post' as const, + text: '{"amount":100000}', + authVersion: 3 as const, + }; + + const syncResult = hmac.calculateRequestHeaders(params); + const asyncResult = await strategy.calculateRequestHeaders(params); + + expect(asyncResult.hmac).to.equal(syncResult.hmac); + expect(asyncResult.tokenHash).to.equal(syncResult.tokenHash); + }); + }); + + describe('verifyResponse', () => { + it('should produce the same result as the sync verifyResponse', async () => { + const token = 'v2x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdefab'; + const responseText = '{"status":"ok"}'; + const method = 'get' as const; + const url = 'https://app.bitgo.com/api/v2/wallet'; + const authVersion = 2 as const; + + const syncHeaders = hmac.calculateRequestHeaders({ + url, + token, + method, + text: '', + authVersion, + }); + + const responseHmac = hmac.calculateHMAC( + token, + hmac.calculateHMACSubject({ + urlPath: url, + text: responseText, + timestamp: syncHeaders.timestamp, + statusCode: 200, + method, + authVersion, + }) + ); + + const params = { + url, + hmac: responseHmac, + statusCode: 200, + text: responseText, + timestamp: syncHeaders.timestamp, + token, + method, + authVersion, + }; + + const syncResult = hmac.verifyResponse(params); + const asyncResult = await strategy.verifyResponse(params); + + expect(asyncResult.isValid).to.equal(syncResult.isValid); + expect(asyncResult.isValid).to.equal(true); + expect(asyncResult.expectedHmac).to.equal(syncResult.expectedHmac); + expect(asyncResult.isInResponseValidityWindow).to.equal(syncResult.isInResponseValidityWindow); + }); + + it('should reject an invalid HMAC', async () => { + const result = await strategy.verifyResponse({ + url: 'https://app.bitgo.com/api/v2/wallet', + hmac: 'invalid-hmac', + statusCode: 200, + text: '{"status":"ok"}', + timestamp: MOCK_TIMESTAMP, + token: 'v2x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdefab', + method: 'get', + authVersion: 2, + }); + + expect(result.isValid).to.equal(false); + }); + }); + + describe('calculateHMAC', () => { + it('should produce the same result as the sync calculateHMAC', async () => { + const key = 'test-key'; + const message = 'test-message'; + + const syncResult = hmac.calculateHMAC(key, message); + const asyncResult = await strategy.calculateHMAC(key, message); + + expect(asyncResult).to.equal(syncResult); + }); + }); +}); diff --git a/modules/sdk-hmac/test/webCryptoStrategy.ts b/modules/sdk-hmac/test/webCryptoStrategy.ts new file mode 100644 index 0000000000..78ce46b127 --- /dev/null +++ b/modules/sdk-hmac/test/webCryptoStrategy.ts @@ -0,0 +1,302 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { WebCryptoHmacStrategy } from '../src/webCryptoStrategy'; +import * as hmac from '../src/hmac'; +import type { CryptoSigning, ITokenStore } from '../src/types'; + +const MOCK_TIMESTAMP = 1672531200000; + +/** + * In-memory token store for testing (IndexedDB is not available in Node.js). + * Stores the {@link CryptoSigning} material, mirroring what IndexedDbTokenStore does. + */ +class InMemoryTokenStore implements ITokenStore { + private signing: CryptoSigning | null = null; + + async save(signing: CryptoSigning): Promise { + this.signing = signing; + } + async load(): Promise { + return this.signing; + } + async remove(): Promise { + this.signing = null; + } +} + +describe('WebCryptoHmacStrategy', () => { + let strategy: WebCryptoHmacStrategy; + let tokenStore: InMemoryTokenStore; + let clock: sinon.SinonFakeTimers; + + const TEST_TOKEN = 'v2x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdefab'; + + before(() => { + clock = sinon.useFakeTimers(MOCK_TIMESTAMP); + }); + + after(() => { + clock.restore(); + }); + + beforeEach(async () => { + tokenStore = new InMemoryTokenStore(); + strategy = new WebCryptoHmacStrategy({ tokenStore, authVersion: 2 }); + await strategy.setToken(TEST_TOKEN); + }); + + describe('token lifecycle', () => { + it('hasToken should be true after setToken', () => { + expect(strategy.hasToken()).to.equal(true); + }); + + it('hasToken should be false after clearToken', async () => { + await strategy.clearToken(); + expect(strategy.hasToken()).to.equal(false); + }); + + it('setToken should persist CryptoSigning (not raw token) to the store', async () => { + const stored = await tokenStore.load(); + expect(stored).to.not.be.null; + expect(stored).to.have.property('cryptoKey'); + expect(stored).to.have.property('tokenHash').that.is.a('string').with.length.greaterThan(0); + }); + + it('clearToken should remove from the token store', async () => { + await strategy.clearToken(); + const stored = await tokenStore.load(); + expect(stored).to.be.null; + }); + + it('restoreToken should recover a previously stored token', async () => { + const newStrategy = new WebCryptoHmacStrategy({ tokenStore, authVersion: 2 }); + expect(newStrategy.hasToken()).to.equal(false); + + const restored = await newStrategy.restoreToken(); + expect(restored).to.equal(true); + expect(newStrategy.hasToken()).to.equal(true); + }); + + it('restoreToken should return false when no token is stored', async () => { + const emptyStore = new InMemoryTokenStore(); + const newStrategy = new WebCryptoHmacStrategy({ tokenStore: emptyStore }); + const restored = await newStrategy.restoreToken(); + expect(restored).to.equal(false); + expect(newStrategy.hasToken()).to.equal(false); + }); + }); + + describe('calculateRequestHeaders', () => { + it('should produce HMAC values matching the Node.js implementation', async () => { + const url = 'https://app.bitgo.com/api/v2/wallet'; + const method = 'get' as const; + const text = ''; + const authVersion = 2 as const; + + const webCryptoResult = await strategy.calculateRequestHeaders({ + url, + token: TEST_TOKEN, + method, + text, + authVersion, + }); + + const nodeResult = hmac.calculateRequestHeaders({ + url, + token: TEST_TOKEN, + method, + text, + authVersion, + }); + + // Timestamps may differ slightly, so we verify structure and token hash + expect(webCryptoResult.tokenHash).to.equal(nodeResult.tokenHash); + expect(webCryptoResult.hmac).to.be.a('string').with.length.greaterThan(0); + expect(webCryptoResult.timestamp).to.equal(MOCK_TIMESTAMP); + }); + + it('should produce matching HMAC for v3 auth with body', async () => { + const url = 'https://app.bitgo.com/api/v2/wallet/send'; + const method = 'post' as const; + const text = '{"amount":100000}'; + const authVersion = 3 as const; + + const v3Strategy = new WebCryptoHmacStrategy({ tokenStore, authVersion: 3 }); + await v3Strategy.setToken(TEST_TOKEN); + + const webCryptoResult = await v3Strategy.calculateRequestHeaders({ + url, + token: TEST_TOKEN, + method, + text, + authVersion, + }); + + const nodeResult = hmac.calculateRequestHeaders({ + url, + token: TEST_TOKEN, + method, + text, + authVersion, + }); + + expect(webCryptoResult.tokenHash).to.equal(nodeResult.tokenHash); + expect(webCryptoResult.hmac).to.equal(nodeResult.hmac); + }); + + it('should throw if no token is set', async () => { + const emptyStrategy = new WebCryptoHmacStrategy({ tokenStore: new InMemoryTokenStore() }); + try { + await emptyStrategy.calculateRequestHeaders({ + url: 'https://app.bitgo.com/api/v2/wallet', + token: '', + method: 'get', + text: '', + authVersion: 2, + }); + expect.fail('should have thrown'); + } catch (e: any) { + expect(e.message).to.contain('No token available'); + } + }); + }); + + describe('verifyResponse', () => { + it('should verify a valid response HMAC', async () => { + const url = 'https://app.bitgo.com/api/v2/wallet'; + const method = 'get' as const; + const authVersion = 2 as const; + const responseText = '{"status":"ok"}'; + const statusCode = 200; + + // Generate a valid response HMAC using the Node.js implementation + const responseHmac = hmac.calculateHMAC( + TEST_TOKEN, + hmac.calculateHMACSubject({ + urlPath: url, + text: responseText, + timestamp: MOCK_TIMESTAMP, + statusCode, + method, + authVersion, + }) + ); + + const result = await strategy.verifyResponse({ + url, + hmac: responseHmac, + statusCode, + text: responseText, + timestamp: MOCK_TIMESTAMP, + token: TEST_TOKEN, + method, + authVersion, + }); + + expect(result.isValid).to.equal(true); + expect(result.isInResponseValidityWindow).to.equal(true); + }); + + it('should reject an invalid HMAC', async () => { + const result = await strategy.verifyResponse({ + url: 'https://app.bitgo.com/api/v2/wallet', + hmac: 'badf00dbadf00dbadf00dbadf00dbadf00dbadf00dbadf00dbadf00dbadf00d00', + statusCode: 200, + text: '{"status":"ok"}', + timestamp: MOCK_TIMESTAMP, + token: TEST_TOKEN, + method: 'get', + authVersion: 2, + }); + + expect(result.isValid).to.equal(false); + }); + + it('should flag responses outside the validity window', async () => { + const url = 'https://app.bitgo.com/api/v2/wallet'; + const method = 'get' as const; + const authVersion = 2 as const; + const responseText = '{"status":"ok"}'; + const oldTimestamp = MOCK_TIMESTAMP - 10 * 60 * 1000; // 10 minutes ago + + const responseHmac = hmac.calculateHMAC( + TEST_TOKEN, + hmac.calculateHMACSubject({ + urlPath: url, + text: responseText, + timestamp: oldTimestamp, + statusCode: 200, + method, + authVersion, + }) + ); + + const result = await strategy.verifyResponse({ + url, + hmac: responseHmac, + statusCode: 200, + text: responseText, + timestamp: oldTimestamp, + token: TEST_TOKEN, + method, + authVersion, + }); + + expect(result.isValid).to.equal(true); + expect(result.isInResponseValidityWindow).to.equal(false); + }); + }); + + describe('calculateHMAC', () => { + it('should produce the same result as the Node.js calculateHMAC', async () => { + const key = 'test-key'; + const message = 'test-message'; + + const nodeResult = hmac.calculateHMAC(key, message); + const webCryptoResult = await strategy.calculateHMAC(key, message); + + expect(webCryptoResult).to.equal(nodeResult); + }); + + it('should work for password-style HMAC (username as key)', async () => { + const username = 'user@example.com'; + const password = 'supersecretpassword'; + + const nodeResult = hmac.calculateHMAC(username, password); + const webCryptoResult = await strategy.calculateHMAC(username, password); + + expect(webCryptoResult).to.equal(nodeResult); + }); + }); + + describe('getAuthHeaders', () => { + it('should return a flat headers dict for fetch()', async () => { + const headers = await strategy.getAuthHeaders({ + url: 'https://app.bitgo.com/api/v2/wallet', + method: 'get', + }); + + expect(headers).to.have.property('Auth-Timestamp'); + expect(headers).to.have.property('Authorization'); + expect(headers).to.have.property('HMAC'); + expect(headers).to.have.property('BitGo-Auth-Version', '2.0'); + expect(headers['Authorization']).to.match(/^Bearer [0-9a-f]+$/); + expect(headers['HMAC']).to.be.a('string').with.length.greaterThan(0); + }); + + it('should set BitGo-Auth-Version to 3.0 for v3 strategy', async () => { + const v3Strategy = new WebCryptoHmacStrategy({ + tokenStore, + authVersion: 3, + }); + await v3Strategy.setToken(TEST_TOKEN); + + const headers = await v3Strategy.getAuthHeaders({ + url: 'https://app.bitgo.com/api/v2/wallet', + method: 'get', + }); + + expect(headers['BitGo-Auth-Version']).to.equal('3.0'); + }); + }); +}); diff --git a/modules/web-demo/package.json b/modules/web-demo/package.json index f4ec5013b7..fee2f4b3c4 100644 --- a/modules/web-demo/package.json +++ b/modules/web-demo/package.json @@ -27,6 +27,7 @@ "@bitgo/abstract-utxo": "^10.19.3", "@bitgo/key-card": "^0.28.31", "@bitgo/sdk-api": "^1.75.3", + "@bitgo/sdk-hmac": "^1.8.0", "@bitgo/sdk-coin-ada": "^4.22.6", "@bitgo/sdk-coin-algo": "^2.9.6", "@bitgo/sdk-coin-avaxc": "^6.5.6", diff --git a/modules/web-demo/src/App.tsx b/modules/web-demo/src/App.tsx index 233f29f071..7210cd93b2 100644 --- a/modules/web-demo/src/App.tsx +++ b/modules/web-demo/src/App.tsx @@ -13,6 +13,7 @@ const WasmMiniscriptComponent = lazy( const EcdsaChallengeComponent = lazy( () => import('@components/EcdsaChallenge'), ); +const WebCryptoAuthComponent = lazy(() => import('@components/WebCryptoAuth')); const Loading = () =>
Loading route...
; @@ -35,6 +36,10 @@ const App = () => { path="/ecdsachallenge" element={} /> + } + /> diff --git a/modules/web-demo/src/components/Navbar/index.tsx b/modules/web-demo/src/components/Navbar/index.tsx index 8cda531e5c..210bd4961f 100644 --- a/modules/web-demo/src/components/Navbar/index.tsx +++ b/modules/web-demo/src/components/Navbar/index.tsx @@ -50,6 +50,12 @@ const Navbar = () => { > Generate Ecdsa Challenge + navigate('/webcrypto-auth')} + > + WebCrypto Auth + ); }; diff --git a/modules/web-demo/src/components/WebCryptoAuth/index.tsx b/modules/web-demo/src/components/WebCryptoAuth/index.tsx new file mode 100644 index 0000000000..98ad74a79f --- /dev/null +++ b/modules/web-demo/src/components/WebCryptoAuth/index.tsx @@ -0,0 +1,467 @@ +import React, { useState, useCallback, useRef, useEffect } from 'react'; +import { BitGoAPI } from '@bitgo/sdk-api'; +import { + WebCryptoHmacStrategy, + IndexedDbTokenStore, + // eslint-disable-next-line import/no-internal-modules +} from '@bitgo/sdk-hmac/browser'; +import { + PageContainer, + TwoColumnLayout, + LeftColumn, + RightColumn, + Section, + SectionTitle, + FormGroup, + Label, + Input, + Button, + StatusBadge, + LogArea, + ErrorText, + SuccessText, +} from './styles'; + +type LogEntry = { time: string; message: string }; + +function ts(): string { + return new Date().toLocaleTimeString('en-US', { hour12: false }); +} + +const DEFAULT_ENV = 'test'; + +const WebCryptoAuth = () => { + const [env, setEnv] = useState(DEFAULT_ENV); + const [customUri, setCustomUri] = useState(''); + + const [strategyReady, setStrategyReady] = useState(false); + const [sdkReady, setSdkReady] = useState(false); + const [tokenRestored, setTokenRestored] = useState(false); + const [loggedIn, setLoggedIn] = useState(false); + const [autoRestoring, setAutoRestoring] = useState(true); + + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [otp, setOtp] = useState(''); + + const [logs, setLogs] = useState([]); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const strategyRef = useRef(null); + const sdkRef = useRef(null); + const logAreaRef = useRef(null); + + const log = useCallback((message: string) => { + setLogs((prev) => [...prev, { time: ts(), message }]); + }, []); + + useEffect(() => { + if (logAreaRef.current) { + logAreaRef.current.scrollTop = logAreaRef.current.scrollHeight; + } + }, [logs]); + + const clearStatus = () => { + setError(null); + setSuccess(null); + }; + + const createSdk = useCallback( + async ( + targetEnv: string, + targetCustomUri: string, + appendLog: (msg: string) => void, + ): Promise<{ + sdk: BitGoAPI; + strategy: WebCryptoHmacStrategy; + restored: boolean; + }> => { + appendLog('Creating WebCryptoHmacStrategy with IndexedDbTokenStore...'); + const strategy = new WebCryptoHmacStrategy({ + tokenStore: new IndexedDbTokenStore(), + authVersion: 2, + }); + + appendLog('Checking IndexedDB for existing CryptoSigning...'); + const restored = await strategy.restoreToken(); + if (restored) { + appendLog( + 'CryptoSigning restored (CryptoKey + tokenHash). No raw token involved.', + ); + } else { + appendLog('No stored CryptoSigning found in IndexedDB.'); + } + + const options: Record = { + hmacAuthStrategy: strategy, + hmacVerification: true, + }; + + if (targetEnv === 'custom' && targetCustomUri) { + options.env = 'custom'; + options.customRootURI = targetCustomUri; + } else { + options.env = targetEnv; + } + + appendLog(`Creating BitGoAPI with env="${options.env as string}"...`); + const sdk = new BitGoAPI(options); + appendLog('BitGoAPI instance created with WebCryptoHmacStrategy.'); + + return { sdk, strategy, restored }; + }, + [], + ); + + // Auto-restore on mount: probe IndexedDB, rebuild SDK, and call /user/me + useEffect(() => { + let cancelled = false; + + (async () => { + try { + log('Auto-restore: probing IndexedDB for existing session...'); + + const { sdk, strategy, restored } = await createSdk( + DEFAULT_ENV, + '', + log, + ); + + if (cancelled) return; + + strategyRef.current = strategy; + sdkRef.current = sdk; + setStrategyReady(true); + setSdkReady(true); + setTokenRestored(restored); + + if (restored) { + setLoggedIn(true); + log('Auto-restore: session found. Testing with GET /user/me ...'); + + try { + const result = await sdk.get(sdk.url('/user/me', 2)).result(); + if (cancelled) return; + log(`Auto-restore: /user/me succeeded.`); + log(`Response: ${JSON.stringify(result, null, 2).slice(0, 500)}`); + setSuccess( + 'Session restored from IndexedDB and verified via /user/me.', + ); + } catch (e: any) { + if (cancelled) return; + log(`Auto-restore: /user/me failed — ${e.message || e}`); + log('Session may be expired. Please log in again.'); + setLoggedIn(false); + setTokenRestored(false); + await strategy.clearToken(); + setError('Stored session expired or invalid. Please log in.'); + } + } else { + log('Auto-restore: no session found. Ready for manual setup.'); + setSuccess('SDK initialized. Use the form to authenticate.'); + } + } catch (e: any) { + if (cancelled) return; + log(`Auto-restore error: ${e.message || e}`); + } finally { + if (!cancelled) setAutoRestoring(false); + } + })(); + + return () => { + cancelled = true; + }; + }, []); + + const handleCreateSdk = useCallback(async () => { + clearStatus(); + try { + const { sdk, strategy, restored } = await createSdk(env, customUri, log); + + strategyRef.current = strategy; + sdkRef.current = sdk; + setStrategyReady(true); + setSdkReady(true); + setTokenRestored(restored); + + if (restored) { + setLoggedIn(true); + setSuccess( + 'SDK initialized with restored CryptoSigning from IndexedDB.', + ); + } else { + setSuccess( + 'SDK initialized. Use the login form below to authenticate.', + ); + } + } catch (e: any) { + setError(e.message || String(e)); + log(`Error: ${e.message || e}`); + } + }, [env, customUri, log, createSdk]); + + const handleLogin = useCallback(async () => { + clearStatus(); + const sdk = sdkRef.current; + const strategy = strategyRef.current; + if (!sdk || !strategy) { + setError('SDK not initialized. Create it first.'); + return; + } + + try { + log(`Authenticating as ${username}...`); + const response = await sdk.authenticate({ + username, + password, + otp, + }); + log('Authentication successful.'); + + const token = response?.access_token; + if (token) { + log('Importing access_token into WebCrypto strategy...'); + await strategy.setToken(token); + log('CryptoSigning saved to IndexedDB. Raw token not stored.'); + setLoggedIn(true); + setSuccess( + `Logged in as ${username}. Refresh the page to see auto-restore.`, + ); + } else { + log('Warning: No access_token in response body.'); + setSuccess('Authenticated, but no access_token returned.'); + } + } catch (e) { + const msg = e.message || String(e); + setError(msg); + log(`Login error: ${msg}`); + } + }, [username, password, otp, log]); + + const handleClearToken = useCallback(async () => { + clearStatus(); + const strategy = strategyRef.current; + if (!strategy) return; + + await strategy.clearToken(); + setLoggedIn(false); + setTokenRestored(false); + log('CryptoSigning cleared from memory and IndexedDB.'); + setSuccess( + 'Session cleared. Refresh the page to confirm auto-restore finds nothing.', + ); + }, [log]); + + const handleTestRequest = useCallback(async () => { + clearStatus(); + const sdk = sdkRef.current; + if (!sdk) { + setError('SDK not initialized.'); + return; + } + + try { + log('GET /api/v2/user/me ...'); + const result = await sdk.get(sdk.url('/user/me', 2)).result(); + log(`Response: ${JSON.stringify(result, null, 2).slice(0, 500)}`); + setSuccess( + 'Authenticated request succeeded. HMAC verified by WebCrypto strategy.', + ); + } catch (e: any) { + const msg = e.message || String(e); + setError(msg); + log(`Request error: ${msg}`); + } + }, [log]); + + const handleTestFetch = useCallback(async () => { + clearStatus(); + const strategy = strategyRef.current; + const sdk = sdkRef.current; + if (!strategy || !sdk) { + setError('SDK/Strategy not initialized.'); + return; + } + + try { + const url = sdk.url('/ping', 2); + log(`Standalone fetch: GET ${url} ...`); + const headers = await strategy.getAuthHeaders({ url, method: 'GET' }); + log(`Auth headers: ${JSON.stringify(headers, null, 2)}`); + + const response = await fetch(url, { headers }); + log(`Response status: ${response.status}`); + + if (strategy.hasToken()) { + const verification = await strategy.verifyFetchResponse({ + url, + method: 'GET', + response, + }); + log(`HMAC valid: ${verification.isValid}`); + log(`In validity window: ${verification.isInResponseValidityWindow}`); + } + + setSuccess('Standalone fetch completed.'); + } catch (e: any) { + const msg = e.message || String(e); + setError(msg); + log(`Fetch error: ${msg}`); + } + }, [log]); + + return ( + +

WebCrypto HMAC Strategy Demo

+

+ Demonstrates BitGoAPI with pluggable WebCrypto-based HMAC signing. All + HMAC operations use crypto.subtle. Only the non-extractable + CryptoKey and token hash are persisted in IndexedDB. On page load, the + component auto-detects an existing session and verifies it with{' '} + GET /user/me. +

+ + + + {/* SDK Setup */} +
+ + 1. Initialize SDK{' '} + + {autoRestoring + ? 'Restoring...' + : sdkReady + ? 'Ready' + : 'Not Created'} + + + + + + + {env === 'custom' && ( + + + setCustomUri(e.target.value)} + placeholder="https://your-bitgo-instance.com" + /> + + )} + + {tokenRestored && ( + + CryptoSigning restored from IndexedDB + + )} +
+ + {/* Login Form */} +
+ + 2. Authenticate{' '} + + {loggedIn ? 'Logged In' : 'Not Logged In'} + + + + + setUsername(e.target.value)} + placeholder="user@example.com" + disabled={!sdkReady} + /> + + + + setPassword(e.target.value)} + placeholder="Password" + disabled={!sdkReady} + /> + + + + setOtp(e.target.value)} + placeholder="000000" + disabled={!sdkReady} + /> + + + +
+ + {/* Test Requests */} +
+ 3. Test Authenticated Requests + + +
+ + {error && {error}} + {success && {success}} +
+ + +
+ Activity Log + + {logs.length === 0 + ? 'Checking IndexedDB for existing session...' + : logs + .map((entry) => `[${entry.time}] ${entry.message}`) + .join('\n')} + +
+
+
+
+ ); +}; + +export default WebCryptoAuth; diff --git a/modules/web-demo/src/components/WebCryptoAuth/styles.tsx b/modules/web-demo/src/components/WebCryptoAuth/styles.tsx new file mode 100644 index 0000000000..7ce40788b1 --- /dev/null +++ b/modules/web-demo/src/components/WebCryptoAuth/styles.tsx @@ -0,0 +1,137 @@ +import styled from 'styled-components'; + +export const PageContainer = styled.div` + padding: 24px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + height: 100%; + overflow-y: auto; + box-sizing: border-box; +`; + +export const TwoColumnLayout = styled.div` + display: flex; + gap: 24px; + align-items: flex-start; +`; + +export const LeftColumn = styled.div` + flex: 1; + min-width: 0; + max-width: 520px; +`; + +export const RightColumn = styled.div` + flex: 1; + min-width: 0; + position: sticky; + top: 0; +`; + +export const Section = styled.div` + margin-bottom: 24px; + padding: 16px; + border: 1px solid #e0e0e0; + border-radius: 8px; + background: #fafafa; +`; + +export const SectionTitle = styled.h4` + margin: 0 0 12px 0; + color: #333; +`; + +export const FormGroup = styled.div` + margin-bottom: 12px; +`; + +export const Label = styled.label` + display: block; + margin-bottom: 4px; + font-size: 13px; + font-weight: 600; + color: #555; +`; + +export const Input = styled.input` + width: 100%; + padding: 8px 10px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 14px; + box-sizing: border-box; + + &:focus { + outline: none; + border-color: #2e8ff0; + box-shadow: 0 0 0 2px rgba(46, 143, 240, 0.2); + } +`; + +export const Button = styled.button<{ variant?: 'danger' | 'secondary' }>` + padding: 8px 16px; + border: none; + border-radius: 4px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + margin-right: 8px; + + background: ${(p) => + p.variant === 'danger' + ? '#dc3545' + : p.variant === 'secondary' + ? '#6c757d' + : '#2e8ff0'}; + color: white; + + &:hover { + opacity: 0.9; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +`; + +export const StatusBadge = styled.span<{ active: boolean }>` + display: inline-block; + padding: 4px 10px; + border-radius: 12px; + font-size: 12px; + font-weight: 600; + background: ${(p) => (p.active ? '#d4edda' : '#f8d7da')}; + color: ${(p) => (p.active ? '#155724' : '#721c24')}; +`; + +export const LogArea = styled.pre` + background: #1e1e1e; + color: #d4d4d4; + padding: 12px; + border-radius: 6px; + font-size: 12px; + line-height: 1.5; + max-height: calc(100vh - 180px); + overflow-y: auto; + white-space: pre-wrap; + word-break: break-all; + margin: 0; +`; + +export const ErrorText = styled.div` + color: #dc3545; + font-size: 13px; + margin-bottom: 12px; + padding: 8px; + background: #f8d7da; + border-radius: 4px; +`; + +export const SuccessText = styled.div` + color: #155724; + font-size: 13px; + margin-bottom: 12px; + padding: 8px; + background: #d4edda; + border-radius: 4px; +`; diff --git a/modules/web-demo/tsconfig.json b/modules/web-demo/tsconfig.json index d5ae73479e..44a1723ed4 100644 --- a/modules/web-demo/tsconfig.json +++ b/modules/web-demo/tsconfig.json @@ -45,6 +45,9 @@ { "path": "../sdk-api" }, + { + "path": "../sdk-hmac" + }, { "path": "../sdk-core" },