Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 66 additions & 30 deletions modules/sdk-api/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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,
};
Expand All @@ -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<superagent.Response> {
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;
}
141 changes: 88 additions & 53 deletions modules/sdk-api/src/bitgoAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -37,7 +38,7 @@ import {
serializeRequestData,
setRequestQueryString,
toBitgoRequest,
verifyResponse,
verifyResponseAsync,
} from './api';
import { decrypt, encrypt } from './encrypt';
import { verifyAddress } from './v1/verifyAddress';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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);
}
Expand Down Expand Up @@ -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<VerifyResponseInfo> {
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,
Expand Down Expand Up @@ -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,
Expand All @@ -782,7 +812,7 @@ export class BitGoAPI implements BitGoBase {
forReset2FA,
initialHash,
fingerprintHash,
}: AuthenticateOptions): ProcessedAuthenticationOptions {
}: AuthenticateOptions): Promise<ProcessedAuthenticationOptions> {
if (!_.isString(username)) {
throw new Error('expected string username');
}
Expand All @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1111,15 +1141,15 @@ export class BitGoAPI implements BitGoBase {

/**
*/
verifyPassword(params: VerifyPasswordOptions = {}): Promise<any> {
async verifyPassword(params: VerifyPasswordOptions = {}): Promise<any> {
if (!_.isString(params.password)) {
throw new Error('missing required string password');
}

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');
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Loading