-
Notifications
You must be signed in to change notification settings - Fork 301
feat: hmac authentication strategy and response verification #8262
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
| } | ||
|
Comment on lines
+267
to
+269
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can we maybe move this to the caller? it's a bit unclear in this context here why we would want to skip these reqs
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. not without introducing a breaking change as part of this. This function is publicly exported, and requiring the client to suddenly validate that would require a breaking change. Everything else this PR is implementing is entirely opt in & transparent to existing clients, so in short - no. |
||
|
|
||
| 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; | ||
| } | ||
|
Comment on lines
+294
to
+296
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. same
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. see above. |
||
|
|
||
| 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; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 () => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. clarity: this deserves to be a method |
||
| 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<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, | ||
|
|
@@ -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<ProcessedAuthenticationOptions> { | ||
| 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,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'); | ||
| } | ||
|
|
@@ -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 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍 good naming pattern