From fb1d834aebab83243dd169a05a754905461c9820 Mon Sep 17 00:00:00 2001 From: Aaron Loe Date: Sat, 7 Mar 2026 10:55:15 -0800 Subject: [PATCH 1/3] feat: hmac authentication strategy and response verification - Updated function to be asynchronous, allowing for better handling of HMAC verification. - Introduced for default HMAC handling and added support for custom strategies. - Integrated for browser compatibility, enabling HMAC signing and verification using the Web Crypto API. - Enhanced to utilize the new HMAC strategies for request signing and response verification. - Added unit tests for the new HMAC strategies and their integration with the BitGoAPI. - Updated web demo to include a new component for WebCrypto authentication. Ticket: CE-10122 --- modules/sdk-api/src/api.ts | 96 ++-- modules/sdk-api/src/bitgoAPI.ts | 141 ++++-- modules/sdk-api/src/types.ts | 5 + modules/sdk-api/test/unit/hmacStrategy.ts | 175 +++++++ modules/sdk-hmac/package.json | 17 + modules/sdk-hmac/src/browser.ts | 2 + modules/sdk-hmac/src/defaultStrategy.ts | 26 + modules/sdk-hmac/src/index.ts | 1 + modules/sdk-hmac/src/types.ts | 49 ++ modules/sdk-hmac/src/webCryptoStrategy.ts | 364 ++++++++++++++ modules/sdk-hmac/test/defaultStrategy.ts | 134 +++++ modules/sdk-hmac/test/webCryptoStrategy.ts | 302 +++++++++++ modules/web-demo/package.json | 1 + modules/web-demo/src/App.tsx | 5 + .../web-demo/src/components/Navbar/index.tsx | 6 + .../src/components/WebCryptoAuth/index.tsx | 467 ++++++++++++++++++ .../src/components/WebCryptoAuth/styles.tsx | 137 +++++ modules/web-demo/tsconfig.json | 3 + 18 files changed, 1848 insertions(+), 83 deletions(-) create mode 100644 modules/sdk-api/test/unit/hmacStrategy.ts create mode 100644 modules/sdk-hmac/src/browser.ts create mode 100644 modules/sdk-hmac/src/defaultStrategy.ts create mode 100644 modules/sdk-hmac/src/webCryptoStrategy.ts create mode 100644 modules/sdk-hmac/test/defaultStrategy.ts create mode 100644 modules/sdk-hmac/test/webCryptoStrategy.ts create mode 100644 modules/web-demo/src/components/WebCryptoAuth/index.tsx create mode 100644 modules/web-demo/src/components/WebCryptoAuth/styles.tsx 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 9e35428c67..5c438dbd5b 100644 --- a/modules/web-demo/package.json +++ b/modules/web-demo/package.json @@ -27,6 +27,7 @@ "@bitgo/abstract-utxo": "^10.19.4", "@bitgo/key-card": "^0.28.32", "@bitgo/sdk-api": "^1.75.4", + "@bitgo/sdk-hmac": "^1.8.0", "@bitgo/sdk-coin-ada": "^4.22.7", "@bitgo/sdk-coin-algo": "^2.9.7", "@bitgo/sdk-coin-avaxc": "^6.5.7", 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" }, From ea9dd57f98cb40de97e7fd99ee5609f7886106ef Mon Sep 17 00:00:00 2001 From: Aaron Loe Date: Thu, 12 Mar 2026 13:57:39 -0700 Subject: [PATCH 2/3] fix: web crypto strategy and demo - use timingSafeEqual for comparing hmac values - enhance web demo to support both auth versions Ticket: CE-10122 --- modules/sdk-hmac/src/webCryptoStrategy.ts | 19 ++++++- .../src/components/WebCryptoAuth/index.tsx | 49 +++++++++++++++---- 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/modules/sdk-hmac/src/webCryptoStrategy.ts b/modules/sdk-hmac/src/webCryptoStrategy.ts index 9fdcf3bda0..7100200c57 100644 --- a/modules/sdk-hmac/src/webCryptoStrategy.ts +++ b/modules/sdk-hmac/src/webCryptoStrategy.ts @@ -65,7 +65,7 @@ function buildHmacSubject(params: { const queryPath = extractQueryPath(params.urlPath); let prefixedText: string; - if (params.statusCode !== undefined && isFinite(params.statusCode) && Number.isInteger(params.statusCode)) { + if (params.statusCode !== undefined && Number.isFinite(params.statusCode) && Number.isInteger(params.statusCode)) { prefixedText = params.authVersion === 3 ? [method.toUpperCase(), params.timestamp, queryPath, params.statusCode].join('|') @@ -91,6 +91,21 @@ async function webCryptoImportHmacKey(rawKey: string): Promise { return crypto.subtle.importKey('raw', encoded, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); } +/** + * Constant-time string comparison to prevent timing side-channel attacks. + * Browser-compatible polyfill for Node's `crypto.timingSafeEqual`. + */ +function timingSafeStringEqual(a: string, b: string): boolean { + const aBytes = new TextEncoder().encode(a); + const bBytes = new TextEncoder().encode(b); + if (aBytes.length !== bBytes.length) return false; + let result = 0; + for (let i = 0; i < aBytes.length; i++) { + result |= aBytes[i] ^ bBytes[i]; + } + return result === 0; +} + async function webCryptoSha256Hex(data: string): Promise { const encoded = new TextEncoder().encode(data); const hash = await crypto.subtle.digest('SHA-256', encoded); @@ -302,7 +317,7 @@ export class WebCryptoHmacStrategy implements IHmacAuthStrategy { params.timestamp >= now - backwardValidityWindow && params.timestamp <= now + forwardValidityWindow; return { - isValid: expectedHmac === params.hmac, + isValid: timingSafeStringEqual(expectedHmac, params.hmac), expectedHmac, signatureSubject: subject as VerifyResponseInfo['signatureSubject'], isInResponseValidityWindow, diff --git a/modules/web-demo/src/components/WebCryptoAuth/index.tsx b/modules/web-demo/src/components/WebCryptoAuth/index.tsx index 98ad74a79f..08ca360cd4 100644 --- a/modules/web-demo/src/components/WebCryptoAuth/index.tsx +++ b/modules/web-demo/src/components/WebCryptoAuth/index.tsx @@ -1,5 +1,6 @@ import React, { useState, useCallback, useRef, useEffect } from 'react'; -import { BitGoAPI } from '@bitgo/sdk-api'; +import { BitGoAPI, BitGoAPIOptions } from '@bitgo/sdk-api'; +import type { EnvironmentName } from '@bitgo/sdk-core'; import { WebCryptoHmacStrategy, IndexedDbTokenStore, @@ -29,10 +30,12 @@ function ts(): string { } const DEFAULT_ENV = 'test'; +const DEFAULT_AUTH_VERSION = 3 as 2 | 3; const WebCryptoAuth = () => { - const [env, setEnv] = useState(DEFAULT_ENV); + const [env, setEnv] = useState(DEFAULT_ENV); const [customUri, setCustomUri] = useState(''); + const [authVersion, setAuthVersion] = useState<2 | 3>(DEFAULT_AUTH_VERSION); const [strategyReady, setStrategyReady] = useState(false); const [sdkReady, setSdkReady] = useState(false); @@ -69,18 +72,21 @@ const WebCryptoAuth = () => { const createSdk = useCallback( async ( - targetEnv: string, + targetEnv: EnvironmentName, targetCustomUri: string, + targetAuthVersion: 2 | 3, appendLog: (msg: string) => void, ): Promise<{ sdk: BitGoAPI; strategy: WebCryptoHmacStrategy; restored: boolean; }> => { - appendLog('Creating WebCryptoHmacStrategy with IndexedDbTokenStore...'); + appendLog( + `Creating WebCryptoHmacStrategy (auth v${targetAuthVersion}) with IndexedDbTokenStore...`, + ); const strategy = new WebCryptoHmacStrategy({ tokenStore: new IndexedDbTokenStore(), - authVersion: 2, + authVersion: targetAuthVersion, }); appendLog('Checking IndexedDB for existing CryptoSigning...'); @@ -93,9 +99,10 @@ const WebCryptoAuth = () => { appendLog('No stored CryptoSigning found in IndexedDB.'); } - const options: Record = { + const options: BitGoAPIOptions = { hmacAuthStrategy: strategy, hmacVerification: true, + authVersion: targetAuthVersion, }; if (targetEnv === 'custom' && targetCustomUri) { @@ -125,6 +132,7 @@ const WebCryptoAuth = () => { const { sdk, strategy, restored } = await createSdk( DEFAULT_ENV, '', + authVersion, log, ); @@ -177,7 +185,12 @@ const WebCryptoAuth = () => { const handleCreateSdk = useCallback(async () => { clearStatus(); try { - const { sdk, strategy, restored } = await createSdk(env, customUri, log); + const { sdk, strategy, restored } = await createSdk( + env, + customUri, + authVersion, + log, + ); strategyRef.current = strategy; sdkRef.current = sdk; @@ -199,7 +212,7 @@ const WebCryptoAuth = () => { setError(e.message || String(e)); log(`Error: ${e.message || e}`); } - }, [env, customUri, log, createSdk]); + }, [env, customUri, authVersion, log, createSdk]); const handleLogin = useCallback(async () => { clearStatus(); @@ -340,7 +353,7 @@ const WebCryptoAuth = () => { + + + + {env === 'custom' && ( From 9cc67195b0eecb49570cedd25b2dbea7582d1820 Mon Sep 17 00:00:00 2001 From: Aaron Loe Date: Mon, 16 Mar 2026 12:02:13 -0700 Subject: [PATCH 3/3] fix: web crypto strategy uses same hmac subject calc use same calculateHMACSubject function for getting the subject to sign as regular hmac flows split up IndexedDB class to separate file & tests other small changes from review Ticket: CE-10122 --- modules/sdk-api/src/bitgoAPI.ts | 146 +++++++++------- modules/sdk-hmac/src/browser.ts | 1 + modules/sdk-hmac/src/indexedDbTokenStore.ts | 90 ++++++++++ modules/sdk-hmac/src/types.ts | 5 + modules/sdk-hmac/src/webCryptoStrategy.ts | 165 ++---------------- modules/sdk-hmac/test/indexedDbTokenStore.ts | 141 +++++++++++++++ .../src/components/WebCryptoAuth/index.tsx | 124 ++++++++++++- 7 files changed, 457 insertions(+), 215 deletions(-) create mode 100644 modules/sdk-hmac/src/indexedDbTokenStore.ts create mode 100644 modules/sdk-hmac/test/indexedDbTokenStore.ts diff --git a/modules/sdk-api/src/bitgoAPI.ts b/modules/sdk-api/src/bitgoAPI.ts index e55ee2abee..549dcb62b4 100644 --- a/modules/sdk-api/src/bitgoAPI.ts +++ b/modules/sdk-api/src/bitgoAPI.ts @@ -377,6 +377,84 @@ export class BitGoAPI implements BitGoBase { return this._authVersion; } + /** + * Signs and sends a v2-authenticated request, then verifies the response HMAC. + * Extracted from the req.then override in requestPatch to keep that method readable. + */ + private async _sendRequestWithHmac({ + req, + method, + url, + data, + strategyAuthenticated, + onfulfilled, + originalThen, + }: { + req: superagent.SuperAgentRequest; + method: RequestMethods; + url: string; + data: string | undefined; + strategyAuthenticated: boolean; + onfulfilled: ((response: superagent.Response) => any) | null | undefined; + originalThen: (onfulfilled: any, onrejected?: any) => Promise; + }): Promise { + 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); + } + } + + /** + * 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); + } + /** * This is a patching function which can apply our authorization * headers to any outbound request. @@ -446,65 +524,15 @@ export class BitGoAPI implements BitGoBase { const data = serializeRequestData(req); - 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); - } - } - - /** - * 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); - })(); - - return sendWithHmac.catch(onrejected); + return this._sendRequestWithHmac({ + req, + method, + url, + data, + strategyAuthenticated, + onfulfilled, + originalThen, + }).catch(onrejected); }; return toBitgoRequest(req); } diff --git a/modules/sdk-hmac/src/browser.ts b/modules/sdk-hmac/src/browser.ts index 2bfa5a033e..72f2bd79e6 100644 --- a/modules/sdk-hmac/src/browser.ts +++ b/modules/sdk-hmac/src/browser.ts @@ -1,2 +1,3 @@ export * from './index'; +export * from './indexedDbTokenStore'; export * from './webCryptoStrategy'; diff --git a/modules/sdk-hmac/src/indexedDbTokenStore.ts b/modules/sdk-hmac/src/indexedDbTokenStore.ts new file mode 100644 index 0000000000..52c060c095 --- /dev/null +++ b/modules/sdk-hmac/src/indexedDbTokenStore.ts @@ -0,0 +1,90 @@ +import type { CryptoSigning, ITokenStore } from './types'; + +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 withDb(fn: (db: IDBDatabase) => Promise): Promise { + const db = await openCryptoDb(); + try { + return await fn(db); + } finally { + db.close(); + } +} + +async function persistCryptoSigning(signing: CryptoSigning): Promise { + if (!hasIndexedDB()) return; + await withDb( + (db) => + 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); + }) + ); +} + +async function loadCryptoSigning(): Promise { + if (!hasIndexedDB()) return null; + return withDb( + (db) => + 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); + }) + ); +} + +async function removeCryptoSigning(): Promise { + if (!hasIndexedDB()) return; + await withDb( + (db) => + 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); + }) + ); +} + +/** + * 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(); + } +} diff --git a/modules/sdk-hmac/src/types.ts b/modules/sdk-hmac/src/types.ts index 3349afd604..2c16a5446e 100644 --- a/modules/sdk-hmac/src/types.ts +++ b/modules/sdk-hmac/src/types.ts @@ -28,6 +28,11 @@ export interface CalculateRequestHeadersOptions = Omit< + CalculateRequestHeadersOptions, + 'token' +>; + export interface RequestHeaders { hmac: string; timestamp: number; diff --git a/modules/sdk-hmac/src/webCryptoStrategy.ts b/modules/sdk-hmac/src/webCryptoStrategy.ts index 7100200c57..6cf259cf0e 100644 --- a/modules/sdk-hmac/src/webCryptoStrategy.ts +++ b/modules/sdk-hmac/src/webCryptoStrategy.ts @@ -1,21 +1,21 @@ /** * 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, + * All browser API usage (crypto.subtle) 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, + CalculateRequestHeadersWebCryptoOptions, RequestHeaders, VerifyResponseOptions, VerifyResponseInfo, } from './types'; +import { calculateHMACSubject } from './hmac'; +import { IndexedDbTokenStore } from './indexedDbTokenStore'; function arrayBufToHex(buffer: ArrayBuffer): string { const bytes = new Uint8Array(buffer); @@ -26,60 +26,6 @@ function arrayBufToHex(buffer: ArrayBuffer): string { 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 && Number.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); @@ -112,96 +58,6 @@ async function webCryptoSha256Hex(data: string): Promise { 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 // --------------------------------------------------------------------------- @@ -279,14 +135,14 @@ export class WebCryptoHmacStrategy implements IHmacAuthStrategy { // --- IHmacAuthStrategy implementation ----------------------------------- - async calculateRequestHeaders(params: CalculateRequestHeadersOptions): Promise { + async calculateRequestHeaders(params: CalculateRequestHeadersWebCryptoOptions): Promise { if (!this.cryptoKey || !this.tokenHashHex) { throw new Error('No token available. Call setToken() or restoreToken() first.'); } const timestamp = Date.now(); - const subject = buildHmacSubject({ + const subject = calculateHMACSubject({ urlPath: params.url, - text: params.text as string, + text: params.text, timestamp, method: params.method, authVersion: params.authVersion, @@ -299,9 +155,9 @@ export class WebCryptoHmacStrategy implements IHmacAuthStrategy { if (!this.cryptoKey) { throw new Error('No token available. Call setToken() or restoreToken() first.'); } - const subject = buildHmacSubject({ + const subject = calculateHMACSubject({ urlPath: params.url, - text: params.text as string, + text: params.text, timestamp: params.timestamp, method: params.method, statusCode: params.statusCode, @@ -344,8 +200,7 @@ export class WebCryptoHmacStrategy implements IHmacAuthStrategy { 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'], + method: params.method as CalculateRequestHeadersWebCryptoOptions['method'], text: params.body ?? '', authVersion: this.authVersion, }); diff --git a/modules/sdk-hmac/test/indexedDbTokenStore.ts b/modules/sdk-hmac/test/indexedDbTokenStore.ts new file mode 100644 index 0000000000..89a78af4ec --- /dev/null +++ b/modules/sdk-hmac/test/indexedDbTokenStore.ts @@ -0,0 +1,141 @@ +import { expect } from 'chai'; +import { IndexedDbTokenStore } from '../src/indexedDbTokenStore'; +import type { CryptoSigning } from '../src/types'; + +/** + * Minimal IndexedDB mock. + * Models a simple in-memory key-value store backed by a plain object. + */ +function makeIndexedDbMock() { + const store: Record = {}; + + function makeRequest(fn: () => T): IDBRequest { + const req = {} as IDBRequest; + Promise.resolve().then(() => { + try { + (req as unknown as { result: T }).result = fn(); + req.onsuccess?.({ target: req } as unknown as Event); + } catch (e) { + (req as unknown as { error: DOMException }).error = e as DOMException; + req.onerror?.({ target: req } as unknown as Event); + } + }); + return req; + } + + function makeTransaction(): IDBTransaction { + const objectStore: Partial = { + put: (value: unknown, key: IDBValidKey) => + makeRequest(() => { + store[key as string] = value; + return key; + }), + get: (key: IDBValidKey) => makeRequest(() => store[key as string] as CryptoSigning | undefined), + delete: (key: IDBValidKey) => + makeRequest(() => { + delete store[key as string]; + return undefined; + }), + }; + + const tx = {} as IDBTransaction; + (tx as unknown as Record).objectStore = () => objectStore; + // Fire oncomplete after the request promise chain settles + Promise.resolve() + .then(() => Promise.resolve()) + .then(() => { + tx.oncomplete?.({} as Event); + }); + return tx; + } + + const db: Partial = { + objectStoreNames: { contains: () => true } as unknown as DOMStringList, + transaction: (_name: string) => makeTransaction(), + close: () => undefined, + createObjectStore: () => ({} as IDBObjectStore), + }; + + return { + open: (_name: string, _version?: number) => { + const openRequest = {} as IDBOpenDBRequest; + Promise.resolve().then(() => { + (openRequest as unknown as { result: IDBDatabase }).result = db as IDBDatabase; + openRequest.onsuccess?.({ target: openRequest } as unknown as Event); + }); + return openRequest; + }, + store, + }; +} + +function setIndexedDB(value: unknown) { + Object.defineProperty(globalThis, 'indexedDB', { value, writable: true, configurable: true }); +} + +describe('IndexedDbTokenStore', () => { + let mockSigning: CryptoSigning; + + before(async () => { + const rawKey = new TextEncoder().encode('test-token-key'); + const cryptoKey = await crypto.subtle.importKey('raw', rawKey, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); + mockSigning = { cryptoKey, tokenHash: 'abc123' }; + }); + + afterEach(() => { + setIndexedDB(undefined); + }); + + it('save then load returns the stored CryptoSigning', async () => { + setIndexedDB(makeIndexedDbMock()); + + const tokenStore = new IndexedDbTokenStore(); + await tokenStore.save(mockSigning); + const loaded = await tokenStore.load(); + + expect(loaded).to.deep.equal(mockSigning); + }); + + it('load returns null when nothing is stored', async () => { + setIndexedDB(makeIndexedDbMock()); + + const tokenStore = new IndexedDbTokenStore(); + const loaded = await tokenStore.load(); + + expect(loaded).to.be.null; + }); + + it('remove clears the stored value', async () => { + setIndexedDB(makeIndexedDbMock()); + + const tokenStore = new IndexedDbTokenStore(); + await tokenStore.save(mockSigning); + await tokenStore.remove(); + const loaded = await tokenStore.load(); + + expect(loaded).to.be.null; + }); + + it('save is a no-op when indexedDB is unavailable', async () => { + setIndexedDB(undefined); + + const tokenStore = new IndexedDbTokenStore(); + await tokenStore.save(mockSigning); + }); + + it('load returns null when indexedDB is unavailable', async () => { + setIndexedDB(undefined); + + const tokenStore = new IndexedDbTokenStore(); + const loaded = await tokenStore.load(); + + expect(loaded).to.be.null; + }); + + it('remove is a no-op when indexedDB is unavailable', async () => { + setIndexedDB(undefined); + + const tokenStore = new IndexedDbTokenStore(); + await tokenStore.remove(); + }); +}); diff --git a/modules/web-demo/src/components/WebCryptoAuth/index.tsx b/modules/web-demo/src/components/WebCryptoAuth/index.tsx index 08ca360cd4..b4ff031862 100644 --- a/modules/web-demo/src/components/WebCryptoAuth/index.tsx +++ b/modules/web-demo/src/components/WebCryptoAuth/index.tsx @@ -47,6 +47,14 @@ const WebCryptoAuth = () => { const [password, setPassword] = useState(''); const [otp, setOtp] = useState(''); + const [apiPath, setApiPath] = useState('/user/me'); + const [apiVersion, setApiVersion] = useState<1 | 2 | 3>(2); + const [apiMethod, setApiMethod] = useState< + 'get' | 'post' | 'put' | 'del' | 'patch' + >('get'); + const [apiBody, setApiBody] = useState(''); + const [apiResult, setApiResult] = useState(null); + const [logs, setLogs] = useState([]); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); @@ -288,6 +296,33 @@ const WebCryptoAuth = () => { } }, [log]); + const handleCustomRequest = useCallback(async () => { + clearStatus(); + setApiResult(null); + const sdk = sdkRef.current; + if (!sdk) { + setError('SDK not initialized.'); + return; + } + try { + const url = sdk.url(apiPath, apiVersion); + log(`${apiMethod.toUpperCase()} ${url} ...`); + const request = sdk[apiMethod](url); + if (apiBody.trim() && apiMethod !== 'get') { + request.send(JSON.parse(apiBody)); + } + const result = await request.result(); + const formatted = JSON.stringify(result, null, 2); + setApiResult(formatted); + log(`Response received (${formatted.length} chars).`); + } catch (e: any) { + const msg = e.message || String(e); + setError(msg); + log(`Request error: ${msg}`); + setApiResult(`Error: ${msg}`); + } + }, [apiPath, apiVersion, apiMethod, apiBody, log]); + const handleTestFetch = useCallback(async () => { clearStatus(); const strategy = strategyRef.current; @@ -459,9 +494,96 @@ const WebCryptoAuth = () => { + {/* Custom API Request */} +
+ 3. Custom API Request + + + + + + + + + + + setApiPath(e.target.value)} + placeholder="/user/me" + disabled={!sdkReady} + /> + + {apiMethod !== 'get' && ( + + +