Skip to content

Commit e57bd5b

Browse files
committed
fix: web crypto strategy and demo
- use timingSafeEqual for comparing hmac values - enhance web demo to support both auth versions Ticket: CE-10122
1 parent 26308ce commit e57bd5b

2 files changed

Lines changed: 57 additions & 11 deletions

File tree

modules/sdk-hmac/src/webCryptoStrategy.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ function buildHmacSubject(params: {
6565
const queryPath = extractQueryPath(params.urlPath);
6666

6767
let prefixedText: string;
68-
if (params.statusCode !== undefined && isFinite(params.statusCode) && Number.isInteger(params.statusCode)) {
68+
if (params.statusCode !== undefined && Number.isFinite(params.statusCode) && Number.isInteger(params.statusCode)) {
6969
prefixedText =
7070
params.authVersion === 3
7171
? [method.toUpperCase(), params.timestamp, queryPath, params.statusCode].join('|')
@@ -91,6 +91,21 @@ async function webCryptoImportHmacKey(rawKey: string): Promise<CryptoKey> {
9191
return crypto.subtle.importKey('raw', encoded, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
9292
}
9393

94+
/**
95+
* Constant-time string comparison to prevent timing side-channel attacks.
96+
* Browser-compatible polyfill for Node's `crypto.timingSafeEqual`.
97+
*/
98+
function timingSafeStringEqual(a: string, b: string): boolean {
99+
const aBytes = new TextEncoder().encode(a);
100+
const bBytes = new TextEncoder().encode(b);
101+
if (aBytes.length !== bBytes.length) return false;
102+
let result = 0;
103+
for (let i = 0; i < aBytes.length; i++) {
104+
result |= aBytes[i] ^ bBytes[i];
105+
}
106+
return result === 0;
107+
}
108+
94109
async function webCryptoSha256Hex(data: string): Promise<string> {
95110
const encoded = new TextEncoder().encode(data);
96111
const hash = await crypto.subtle.digest('SHA-256', encoded);
@@ -302,7 +317,7 @@ export class WebCryptoHmacStrategy implements IHmacAuthStrategy {
302317
params.timestamp >= now - backwardValidityWindow && params.timestamp <= now + forwardValidityWindow;
303318

304319
return {
305-
isValid: expectedHmac === params.hmac,
320+
isValid: timingSafeStringEqual(expectedHmac, params.hmac),
306321
expectedHmac,
307322
signatureSubject: subject as VerifyResponseInfo['signatureSubject'],
308323
isInResponseValidityWindow,

modules/web-demo/src/components/WebCryptoAuth/index.tsx

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React, { useState, useCallback, useRef, useEffect } from 'react';
2-
import { BitGoAPI } from '@bitgo/sdk-api';
2+
import { BitGoAPI, BitGoAPIOptions } from '@bitgo/sdk-api';
3+
import type { EnvironmentName } from '@bitgo/sdk-core';
34
import {
45
WebCryptoHmacStrategy,
56
IndexedDbTokenStore,
@@ -29,10 +30,12 @@ function ts(): string {
2930
}
3031

3132
const DEFAULT_ENV = 'test';
33+
const DEFAULT_AUTH_VERSION = 3 as 2 | 3;
3234

3335
const WebCryptoAuth = () => {
34-
const [env, setEnv] = useState(DEFAULT_ENV);
36+
const [env, setEnv] = useState<EnvironmentName>(DEFAULT_ENV);
3537
const [customUri, setCustomUri] = useState('');
38+
const [authVersion, setAuthVersion] = useState<2 | 3>(DEFAULT_AUTH_VERSION);
3639

3740
const [strategyReady, setStrategyReady] = useState(false);
3841
const [sdkReady, setSdkReady] = useState(false);
@@ -69,18 +72,21 @@ const WebCryptoAuth = () => {
6972

7073
const createSdk = useCallback(
7174
async (
72-
targetEnv: string,
75+
targetEnv: EnvironmentName,
7376
targetCustomUri: string,
77+
targetAuthVersion: 2 | 3,
7478
appendLog: (msg: string) => void,
7579
): Promise<{
7680
sdk: BitGoAPI;
7781
strategy: WebCryptoHmacStrategy;
7882
restored: boolean;
7983
}> => {
80-
appendLog('Creating WebCryptoHmacStrategy with IndexedDbTokenStore...');
84+
appendLog(
85+
`Creating WebCryptoHmacStrategy (auth v${targetAuthVersion}) with IndexedDbTokenStore...`,
86+
);
8187
const strategy = new WebCryptoHmacStrategy({
8288
tokenStore: new IndexedDbTokenStore(),
83-
authVersion: 2,
89+
authVersion: targetAuthVersion,
8490
});
8591

8692
appendLog('Checking IndexedDB for existing CryptoSigning...');
@@ -93,9 +99,10 @@ const WebCryptoAuth = () => {
9399
appendLog('No stored CryptoSigning found in IndexedDB.');
94100
}
95101

96-
const options: Record<string, unknown> = {
102+
const options: BitGoAPIOptions = {
97103
hmacAuthStrategy: strategy,
98104
hmacVerification: true,
105+
authVersion: targetAuthVersion,
99106
};
100107

101108
if (targetEnv === 'custom' && targetCustomUri) {
@@ -125,6 +132,7 @@ const WebCryptoAuth = () => {
125132
const { sdk, strategy, restored } = await createSdk(
126133
DEFAULT_ENV,
127134
'',
135+
authVersion,
128136
log,
129137
);
130138

@@ -177,7 +185,12 @@ const WebCryptoAuth = () => {
177185
const handleCreateSdk = useCallback(async () => {
178186
clearStatus();
179187
try {
180-
const { sdk, strategy, restored } = await createSdk(env, customUri, log);
188+
const { sdk, strategy, restored } = await createSdk(
189+
env,
190+
customUri,
191+
authVersion,
192+
log,
193+
);
181194

182195
strategyRef.current = strategy;
183196
sdkRef.current = sdk;
@@ -199,7 +212,7 @@ const WebCryptoAuth = () => {
199212
setError(e.message || String(e));
200213
log(`Error: ${e.message || e}`);
201214
}
202-
}, [env, customUri, log, createSdk]);
215+
}, [env, customUri, authVersion, log, createSdk]);
203216

204217
const handleLogin = useCallback(async () => {
205218
clearStatus();
@@ -340,7 +353,7 @@ const WebCryptoAuth = () => {
340353
<Label>Environment</Label>
341354
<select
342355
value={env}
343-
onChange={(e) => setEnv(e.target.value)}
356+
onChange={(e) => setEnv(e.target.value as EnvironmentName)}
344357
style={{
345358
padding: '8px',
346359
borderRadius: 4,
@@ -353,6 +366,24 @@ const WebCryptoAuth = () => {
353366
<option value="custom">custom</option>
354367
</select>
355368
</FormGroup>
369+
<FormGroup>
370+
<Label>Auth Version</Label>
371+
<select
372+
value={authVersion}
373+
onChange={(e) =>
374+
setAuthVersion(Number(e.target.value) as 2 | 3)
375+
}
376+
style={{
377+
padding: '8px',
378+
borderRadius: 4,
379+
border: '1px solid #ccc',
380+
width: '100%',
381+
}}
382+
>
383+
<option value={2}>v2</option>
384+
<option value={3}>v3</option>
385+
</select>
386+
</FormGroup>
356387
{env === 'custom' && (
357388
<FormGroup>
358389
<Label>Custom Root URI</Label>

0 commit comments

Comments
 (0)