Skip to content

Commit 49529e2

Browse files
committed
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
1 parent 2461908 commit 49529e2

File tree

18 files changed

+1848
-83
lines changed

18 files changed

+1848
-83
lines changed

modules/sdk-api/src/api.ts

Lines changed: 66 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import querystring from 'querystring';
1111

1212
import { ApiResponseError, BitGoRequest } from '@bitgo/sdk-core';
1313

14-
import { AuthVersion, VerifyResponseOptions } from './types';
14+
import { AuthVersion, VerifyResponseInfo, VerifyResponseOptions } from './types';
1515
import { BitGoAPI } from './bitgoAPI';
1616

1717
const debug = Debug('bitgo:api');
@@ -214,44 +214,23 @@ export function setRequestQueryString(req: superagent.SuperAgentRequest): void {
214214
}
215215

216216
/**
217-
* Verify that the response received from the server is signed correctly.
218-
* Right now, it is very permissive with the timestamp variance.
217+
* Validate a completed verification response and throw a descriptive `ApiResponseError` if it
218+
* indicates the response is invalid or outside the acceptable time window.
219219
*/
220-
export function verifyResponse(
220+
function assertVerificationResponse(
221221
bitgo: BitGoAPI,
222222
token: string | undefined,
223-
method: VerifyResponseOptions['method'],
224223
req: superagent.SuperAgentRequest,
225224
response: superagent.Response,
226-
authVersion: AuthVersion
227-
): superagent.Response {
228-
// we can't verify the response if we're not authenticated
229-
if (!req.isV2Authenticated || !req.authenticationToken) {
230-
return response;
231-
}
232-
233-
const verificationResponse = bitgo.verifyResponse({
234-
url: req.url,
235-
hmac: response.header.hmac,
236-
statusCode: response.status,
237-
text: response.text,
238-
timestamp: response.header.timestamp,
239-
token: req.authenticationToken,
240-
method,
241-
authVersion,
242-
});
243-
225+
verificationResponse: VerifyResponseInfo
226+
): void {
244227
if (!verificationResponse.isValid) {
245-
// calculate the HMAC
246-
const receivedHmac = response.header.hmac;
247-
const expectedHmac = verificationResponse.expectedHmac;
248-
const signatureSubject = verificationResponse.signatureSubject;
249228
// Log only the first 10 characters of the token to ensure the full token isn't logged.
250229
const partialBitgoToken = token ? token.substring(0, 10) : '';
251230
const errorDetails = {
252-
expectedHmac,
253-
receivedHmac,
254-
hmacInput: signatureSubject,
231+
expectedHmac: verificationResponse.expectedHmac,
232+
receivedHmac: response.header.hmac,
233+
hmacInput: verificationResponse.signatureSubject,
255234
requestToken: req.authenticationToken,
256235
bitgoToken: partialBitgoToken,
257236
};
@@ -271,5 +250,62 @@ export function verifyResponse(
271250
errorDetails
272251
);
273252
}
253+
}
254+
255+
/**
256+
* Verify that the response received from the server is signed correctly.
257+
* Right now, it is very permissive with the timestamp variance.
258+
*/
259+
export function verifyResponse(
260+
bitgo: BitGoAPI,
261+
token: string | undefined,
262+
method: VerifyResponseOptions['method'],
263+
req: superagent.SuperAgentRequest,
264+
response: superagent.Response,
265+
authVersion: AuthVersion
266+
): superagent.Response {
267+
if (!req.isV2Authenticated || !req.authenticationToken) {
268+
return response;
269+
}
270+
271+
const verificationResponse = bitgo.verifyResponse({
272+
url: req.url,
273+
hmac: response.header.hmac,
274+
statusCode: response.status,
275+
text: response.text,
276+
timestamp: response.header.timestamp,
277+
token: req.authenticationToken,
278+
method,
279+
authVersion,
280+
});
281+
282+
assertVerificationResponse(bitgo, token, req, response, verificationResponse);
283+
return response;
284+
}
285+
286+
export async function verifyResponseAsync(
287+
bitgo: BitGoAPI,
288+
token: string | undefined,
289+
method: VerifyResponseOptions['method'],
290+
req: superagent.SuperAgentRequest,
291+
response: superagent.Response,
292+
authVersion: AuthVersion
293+
): Promise<superagent.Response> {
294+
if (!req.isV2Authenticated || !req.authenticationToken) {
295+
return response;
296+
}
297+
298+
const verificationResponse = await bitgo.verifyResponseAsync({
299+
url: req.url,
300+
hmac: response.header.hmac,
301+
statusCode: response.status,
302+
text: response.text,
303+
timestamp: response.header.timestamp,
304+
token: req.authenticationToken,
305+
method,
306+
authVersion,
307+
});
308+
309+
assertVerificationResponse(bitgo, token, req, response, verificationResponse);
274310
return response;
275311
}

modules/sdk-api/src/bitgoAPI.ts

Lines changed: 88 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
sanitizeLegacyPath,
2424
} from '@bitgo/sdk-core';
2525
import * as sdkHmac from '@bitgo/sdk-hmac';
26+
import { DefaultHmacAuthStrategy, type IHmacAuthStrategy } from '@bitgo/sdk-hmac';
2627
import * as utxolib from '@bitgo/utxo-lib';
2728
import { bip32, ECPairInterface } from '@bitgo/utxo-lib';
2829
import * as bitcoinMessage from 'bitcoinjs-message';
@@ -37,7 +38,7 @@ import {
3738
serializeRequestData,
3839
setRequestQueryString,
3940
toBitgoRequest,
40-
verifyResponse,
41+
verifyResponseAsync,
4142
} from './api';
4243
import { decrypt, encrypt } from './encrypt';
4344
import { verifyAddress } from './v1/verifyAddress';
@@ -134,6 +135,7 @@ export class BitGoAPI implements BitGoBase {
134135
private _customProxyAgent?: Agent;
135136
private _requestIdPrefix?: string;
136137
private getAdditionalHeadersCb?: AdditionalHeadersCallback;
138+
protected _hmacAuthStrategy: IHmacAuthStrategy;
137139

138140
constructor(params: BitGoAPIOptions = {}) {
139141
this.getAdditionalHeadersCb = params.getAdditionalHeadersCb;
@@ -309,6 +311,7 @@ export class BitGoAPI implements BitGoBase {
309311
}
310312

311313
this._customProxyAgent = params.customProxyAgent;
314+
this._hmacAuthStrategy = params.hmacAuthStrategy ?? new DefaultHmacAuthStrategy();
312315

313316
// Only fetch constants from constructor if clientConstants was not provided
314317
if (!clientConstants) {
@@ -423,9 +426,12 @@ export class BitGoAPI implements BitGoBase {
423426
// Set the request timeout to just above 5 minutes by default
424427
req.timeout((process.env.BITGO_TIMEOUT as any) * 1000 || 305 * 1000);
425428

426-
// if there is no token, and we're not logged in, the request cannot be v2 authenticated
429+
// The strategy may have its own signing material (e.g. a CryptoKey
430+
// restored from IndexedDB) independent of this._token.
431+
const strategyAuthenticated = this._hmacAuthStrategy.isAuthenticated?.() ?? false;
432+
427433
req.isV2Authenticated = true;
428-
req.authenticationToken = this._token;
434+
req.authenticationToken = this._token ?? (strategyAuthenticated ? 'strategy-authenticated' : undefined);
429435
// some of the older tokens appear to be only 40 characters long
430436
if ((this._token && this._token.length !== 67 && this._token.indexOf('v2x') !== 0) || req.forceV1Auth) {
431437
// use the old method
@@ -439,51 +445,66 @@ export class BitGoAPI implements BitGoBase {
439445
req.set('BitGo-Auth-Version', this._authVersion === 3 ? '3.0' : '2.0');
440446

441447
const data = serializeRequestData(req);
442-
if (this._token) {
443-
setRequestQueryString(req);
444-
445-
const requestProperties = this.calculateRequestHeaders({
446-
url: req.url,
447-
token: this._token,
448-
method,
449-
text: data || '',
450-
authVersion: this._authVersion,
451-
});
452-
req.set('Auth-Timestamp', requestProperties.timestamp.toString());
453-
454-
// we're not sending the actual token, but only its hash
455-
req.set('Authorization', 'Bearer ' + requestProperties.tokenHash);
456-
debug('sending v2 %s request to %s with token %s', method, url, this._token?.substr(0, 8));
457448

458-
// set the HMAC
459-
req.set('HMAC', requestProperties.hmac);
460-
}
449+
const sendWithHmac = (async () => {
450+
if (this._token || strategyAuthenticated) {
451+
setRequestQueryString(req);
452+
453+
const requestProperties = await this._hmacAuthStrategy.calculateRequestHeaders({
454+
url: req.url,
455+
token: this._token ?? '',
456+
method,
457+
text: data || '',
458+
authVersion: this._authVersion,
459+
});
460+
req.set('Auth-Timestamp', requestProperties.timestamp.toString());
461+
462+
req.set('Authorization', 'Bearer ' + requestProperties.tokenHash);
463+
debug(
464+
'sending v2 %s request to %s with token %s',
465+
method,
466+
url,
467+
this._token?.substr(0, 8) ?? '(strategy-managed)'
468+
);
469+
470+
req.set('HMAC', requestProperties.hmac);
471+
}
461472

462-
if (this.getAdditionalHeadersCb) {
463-
const additionalHeaders = this.getAdditionalHeadersCb(method, url, data);
464-
for (const { key, value } of additionalHeaders) {
465-
req.set(key, value);
473+
if (this.getAdditionalHeadersCb) {
474+
const additionalHeaders = this.getAdditionalHeadersCb(method, url, data);
475+
for (const { key, value } of additionalHeaders) {
476+
req.set(key, value);
477+
}
466478
}
467-
}
468479

469-
/**
470-
* Verify the response before calling the original onfulfilled handler,
471-
* and make sure onrejected is called if a verification error is encountered
472-
*/
473-
const newOnFulfilled = onfulfilled
474-
? (response: superagent.Response) => {
475-
// HMAC verification is only allowed to be skipped in certain environments.
476-
// This is checked in the constructor, but checking it again at request time
477-
// will help prevent against tampering of this property after the object is created
478-
if (!this._hmacVerification && !common.Environments[this.getEnv()].hmacVerificationEnforced) {
479-
return onfulfilled(response);
480+
/**
481+
* Verify the response before calling the original onfulfilled handler,
482+
* and make sure onrejected is called if a verification error is encountered
483+
*/
484+
const newOnFulfilled = onfulfilled
485+
? async (response: superagent.Response) => {
486+
// HMAC verification is only allowed to be skipped in certain environments.
487+
// This is checked in the constructor, but checking it again at request time
488+
// will help prevent against tampering of this property after the object is created
489+
if (!this._hmacVerification && !common.Environments[this.getEnv()].hmacVerificationEnforced) {
490+
return onfulfilled(response);
491+
}
492+
493+
const verifiedResponse = await verifyResponseAsync(
494+
this,
495+
this._token,
496+
method,
497+
req,
498+
response,
499+
this._authVersion
500+
);
501+
return onfulfilled(verifiedResponse);
480502
}
503+
: null;
504+
return originalThen(newOnFulfilled);
505+
})();
481506

482-
const verifiedResponse = verifyResponse(this, this._token, method, req, response, this._authVersion);
483-
return onfulfilled(verifiedResponse);
484-
}
485-
: null;
486-
return originalThen(newOnFulfilled).catch(onrejected);
507+
return sendWithHmac.catch(onrejected);
487508
};
488509
return toBitgoRequest(req);
489510
}
@@ -545,12 +566,21 @@ export class BitGoAPI implements BitGoBase {
545566
}
546567

547568
/**
548-
* Verify the HMAC for an HTTP response
569+
* Verify the HMAC for an HTTP response (synchronous, uses sdk-hmac directly).
570+
* Kept for backward compatibility with external callers.
549571
*/
550572
verifyResponse(params: VerifyResponseOptions): VerifyResponseInfo {
551573
return sdkHmac.verifyResponse({ ...params, authVersion: this._authVersion });
552574
}
553575

576+
/**
577+
* Verify the HMAC for an HTTP response via the configured strategy (async).
578+
* Used internally by the request pipeline.
579+
*/
580+
verifyResponseAsync(params: VerifyResponseOptions): Promise<VerifyResponseInfo> {
581+
return this._hmacAuthStrategy.verifyResponse({ ...params, authVersion: this._authVersion });
582+
}
583+
554584
/**
555585
* Fetch useful constant values from the BitGo server.
556586
* These values do change infrequently, so they need to be fetched,
@@ -772,7 +802,7 @@ export class BitGoAPI implements BitGoBase {
772802
* Process the username, password and otp into an object containing the username and hashed password, ready to
773803
* send to bitgo for authentication.
774804
*/
775-
preprocessAuthenticationParams({
805+
async preprocessAuthenticationParams({
776806
username,
777807
password,
778808
otp,
@@ -782,7 +812,7 @@ export class BitGoAPI implements BitGoBase {
782812
forReset2FA,
783813
initialHash,
784814
fingerprintHash,
785-
}: AuthenticateOptions): ProcessedAuthenticationOptions {
815+
}: AuthenticateOptions): Promise<ProcessedAuthenticationOptions> {
786816
if (!_.isString(username)) {
787817
throw new Error('expected string username');
788818
}
@@ -793,7 +823,7 @@ export class BitGoAPI implements BitGoBase {
793823

794824
const lowerName = username.toLowerCase();
795825
// Calculate the password HMAC so we don't send clear-text passwords
796-
const hmacPassword = this.calculateHMAC(lowerName, password);
826+
const hmacPassword = await this._hmacAuthStrategy.calculateHMAC(lowerName, password);
797827

798828
const authParams: ProcessedAuthenticationOptions = {
799829
email: lowerName,
@@ -944,7 +974,7 @@ export class BitGoAPI implements BitGoBase {
944974
}
945975

946976
const forceV1Auth = !!params.forceV1Auth;
947-
const authParams = this.preprocessAuthenticationParams(params);
977+
const authParams = await this.preprocessAuthenticationParams(params);
948978
const password = params.password;
949979

950980
if (this._token) {
@@ -981,7 +1011,7 @@ export class BitGoAPI implements BitGoBase {
9811011
this._ecdhXprv = responseDetails.ecdhXprv;
9821012

9831013
// verify the response's authenticity
984-
verifyResponse(this, responseDetails.token, 'post', request, response, this._authVersion);
1014+
await verifyResponseAsync(this, responseDetails.token, 'post', request, response, this._authVersion);
9851015

9861016
// add the remaining component for easier access
9871017
response.body.access_token = this._token;
@@ -1111,15 +1141,15 @@ export class BitGoAPI implements BitGoBase {
11111141

11121142
/**
11131143
*/
1114-
verifyPassword(params: VerifyPasswordOptions = {}): Promise<any> {
1144+
async verifyPassword(params: VerifyPasswordOptions = {}): Promise<any> {
11151145
if (!_.isString(params.password)) {
11161146
throw new Error('missing required string password');
11171147
}
11181148

11191149
if (!this._user || !this._user.username) {
11201150
throw new Error('no current user');
11211151
}
1122-
const hmacPassword = this.calculateHMAC(this._user.username, params.password);
1152+
const hmacPassword = await this._hmacAuthStrategy.calculateHMAC(this._user.username, params.password);
11231153

11241154
return this.post(this.url('/user/verifypassword')).send({ password: hmacPassword }).result('valid');
11251155
}
@@ -1269,7 +1299,7 @@ export class BitGoAPI implements BitGoBase {
12691299
}
12701300

12711301
// verify the authenticity of the server's response before proceeding any further
1272-
verifyResponse(this, this._token, 'post', request, response, this._authVersion);
1302+
await verifyResponseAsync(this, this._token, 'post', request, response, this._authVersion);
12731303

12741304
const responseDetails = this.handleTokenIssuance(response.body);
12751305
response.body.token = responseDetails.token;
@@ -1924,12 +1954,17 @@ export class BitGoAPI implements BitGoBase {
19241954
const v1KeychainUpdatePWResult = await this.keychains().updatePassword(updateKeychainPasswordParams);
19251955
const v2Keychains = await this.coin(coin).keychains().updatePassword(updateKeychainPasswordParams);
19261956

1957+
const [hmacOldPassword, hmacNewPassword] = await Promise.all([
1958+
this._hmacAuthStrategy.calculateHMAC(user.username, oldPassword),
1959+
this._hmacAuthStrategy.calculateHMAC(user.username, newPassword),
1960+
]);
1961+
19271962
const updatePasswordParams = {
19281963
keychains: v1KeychainUpdatePWResult.keychains,
19291964
v2_keychains: v2Keychains,
19301965
version: v1KeychainUpdatePWResult.version,
1931-
oldPassword: this.calculateHMAC(user.username, oldPassword),
1932-
password: this.calculateHMAC(user.username, newPassword),
1966+
oldPassword: hmacOldPassword,
1967+
password: hmacNewPassword,
19331968
};
19341969

19351970
// Calculate payload size in KB

0 commit comments

Comments
 (0)