Skip to content

Commit 9248035

Browse files
authored
feat: Add platform support for async hashing. (#573)
This adds platform support for async hashing for use in client-side SDKs. It does not implement async hashing for any existing platform, but provides it as an option to allow for use of standard browser APIs. Allowing the usage of standard browser crypto APIs means that browser SDKs will not need to include an additional dependency to replicate built-in functionality.
1 parent fca4d92 commit 9248035

File tree

13 files changed

+142
-66
lines changed

13 files changed

+142
-66
lines changed

packages/shared/common/src/api/platform/Crypto.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,19 @@
77
*/
88
export interface Hasher {
99
update(data: string): Hasher;
10-
digest(encoding: string): string;
10+
/**
11+
* Note: All server SDKs MUST implement synchronous digest.
12+
*
13+
* Server SDKs have high performance requirements for bucketing users.
14+
*/
15+
digest?(encoding: string): string;
16+
17+
/**
18+
* Note: Client-side SDKs MUST implement either synchronous or asynchronous digest.
19+
*
20+
* Client SDKs do not have high throughput hashing operations.
21+
*/
22+
asyncDigest?(encoding: string): Promise<string>;
1123
}
1224

1325
/**
@@ -17,7 +29,7 @@ export interface Hasher {
1729
*
1830
* The has implementation must support digesting to 'hex'.
1931
*/
20-
export interface Hmac extends Hasher {
32+
export interface Hmac {
2133
update(data: string): Hasher;
2234
digest(encoding: string): string;
2335
}
@@ -27,6 +39,9 @@ export interface Hmac extends Hasher {
2739
*/
2840
export interface Crypto {
2941
createHash(algorithm: string): Hasher;
30-
createHmac(algorithm: string, key: string): Hmac;
42+
/**
43+
* Note: Server SDKs MUST implement createHmac.
44+
*/
45+
createHmac?(algorithm: string, key: string): Hmac;
3146
randomUUID(): string;
3247
}

packages/shared/sdk-client/__tests__/context/addAutoEnv.test.ts

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,7 @@ describe('automatic environment attributes', () => {
337337
});
338338

339339
describe('addApplicationInfo', () => {
340-
test('add id, version, name, versionName', () => {
340+
test('add id, version, name, versionName', async () => {
341341
config = new Configuration({
342342
applicationInfo: {
343343
id: 'com.from-config.ld',
@@ -346,7 +346,7 @@ describe('automatic environment attributes', () => {
346346
versionName: 'test-ld-version-name',
347347
},
348348
});
349-
const ldApplication = addApplicationInfo(mockPlatform, config);
349+
const ldApplication = await addApplicationInfo(mockPlatform, config);
350350

351351
expect(ldApplication).toEqual({
352352
envAttributesVersion: '1.0',
@@ -358,8 +358,8 @@ describe('automatic environment attributes', () => {
358358
});
359359
});
360360

361-
test('add auto env application id, name, version', () => {
362-
const ldApplication = addApplicationInfo(mockPlatform, config);
361+
test('add auto env application id, name, version', async () => {
362+
const ldApplication = await addApplicationInfo(mockPlatform, config);
363363

364364
expect(ldApplication).toEqual({
365365
envAttributesVersion: '1.0',
@@ -370,7 +370,7 @@ describe('automatic environment attributes', () => {
370370
});
371371
});
372372

373-
test('final return value should not contain falsy values', () => {
373+
test('final return value should not contain falsy values', async () => {
374374
const mockData = info.platformData();
375375
info.platformData = jest.fn().mockReturnValueOnce({
376376
...mockData,
@@ -384,7 +384,7 @@ describe('automatic environment attributes', () => {
384384
},
385385
});
386386

387-
const ldApplication = addApplicationInfo(mockPlatform, config);
387+
const ldApplication = await addApplicationInfo(mockPlatform, config);
388388

389389
expect(ldApplication).toEqual({
390390
envAttributesVersion: '1.0',
@@ -393,15 +393,15 @@ describe('automatic environment attributes', () => {
393393
});
394394
});
395395

396-
test('omit if customer and auto env data are unavailable', () => {
396+
test('omit if customer and auto env data are unavailable', async () => {
397397
info.platformData = jest.fn().mockReturnValueOnce({});
398398

399-
const ldApplication = addApplicationInfo(mockPlatform, config);
399+
const ldApplication = await addApplicationInfo(mockPlatform, config);
400400

401401
expect(ldApplication).toBeUndefined();
402402
});
403403

404-
test('omit if customer unavailable and auto env data are falsy', () => {
404+
test('omit if customer unavailable and auto env data are falsy', async () => {
405405
const mockData = info.platformData();
406406
info.platformData = jest.fn().mockReturnValueOnce({
407407
ld_application: {
@@ -412,27 +412,27 @@ describe('automatic environment attributes', () => {
412412
},
413413
});
414414

415-
const ldApplication = addApplicationInfo(mockPlatform, config);
415+
const ldApplication = await addApplicationInfo(mockPlatform, config);
416416

417417
expect(ldApplication).toBeUndefined();
418418
});
419419

420-
test('omit if customer data is unavailable and auto env data only contains key and attributesVersion', () => {
420+
test('omit if customer data is unavailable and auto env data only contains key and attributesVersion', async () => {
421421
info.platformData = jest.fn().mockReturnValueOnce({
422422
ld_application: { key: 'key-from-sdk', envAttributesVersion: '0.0.1' },
423423
});
424424

425-
const ldApplication = addApplicationInfo(mockPlatform, config);
425+
const ldApplication = await addApplicationInfo(mockPlatform, config);
426426

427427
expect(ldApplication).toBeUndefined();
428428
});
429429

430-
test('omit if no id specified', () => {
430+
test('omit if no id specified', async () => {
431431
info.platformData = jest
432432
.fn()
433433
.mockReturnValueOnce({ ld_application: { version: null, locale: '' } });
434434
config = new Configuration({ applicationInfo: { version: '1.2.3' } });
435-
const ldApplication = addApplicationInfo(mockPlatform, config);
435+
const ldApplication = await addApplicationInfo(mockPlatform, config);
436436

437437
expect(ldApplication).toBeUndefined();
438438
});

packages/shared/sdk-client/__tests__/flag-manager/FlagPersistence.test.ts

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,12 @@ describe('FlagPersistence tests', () => {
141141

142142
await fpUnderTest.init(context, flags);
143143

144-
const contextDataKey = namespaceForContextData(mockPlatform.crypto, TEST_NAMESPACE, context);
145-
const contextIndexKey = namespaceForContextIndex(TEST_NAMESPACE);
144+
const contextDataKey = await namespaceForContextData(
145+
mockPlatform.crypto,
146+
TEST_NAMESPACE,
147+
context,
148+
);
149+
const contextIndexKey = await namespaceForContextIndex(TEST_NAMESPACE);
146150
expect(await memoryStorage.get(contextIndexKey)).toContain(contextDataKey);
147151
expect(await memoryStorage.get(contextDataKey)).toContain('flagA');
148152
});
@@ -175,9 +179,17 @@ describe('FlagPersistence tests', () => {
175179
await fpUnderTest.init(context1, flags);
176180
await fpUnderTest.init(context2, flags);
177181

178-
const context1DataKey = namespaceForContextData(mockPlatform.crypto, TEST_NAMESPACE, context1);
179-
const context2DataKey = namespaceForContextData(mockPlatform.crypto, TEST_NAMESPACE, context2);
180-
const contextIndexKey = namespaceForContextIndex(TEST_NAMESPACE);
182+
const context1DataKey = await namespaceForContextData(
183+
mockPlatform.crypto,
184+
TEST_NAMESPACE,
185+
context1,
186+
);
187+
const context2DataKey = await namespaceForContextData(
188+
mockPlatform.crypto,
189+
TEST_NAMESPACE,
190+
context2,
191+
);
192+
const contextIndexKey = await namespaceForContextIndex(TEST_NAMESPACE);
181193

182194
const indexData = await memoryStorage.get(contextIndexKey);
183195
expect(indexData).not.toContain(context1DataKey);
@@ -213,7 +225,7 @@ describe('FlagPersistence tests', () => {
213225

214226
await fpUnderTest.init(context, flags);
215227
await fpUnderTest.init(context, flags);
216-
const contextIndexKey = namespaceForContextIndex(TEST_NAMESPACE);
228+
const contextIndexKey = await namespaceForContextIndex(TEST_NAMESPACE);
217229

218230
const indexData = await memoryStorage.get(contextIndexKey);
219231
expect(indexData).toContain(`"timestamp":2`);
@@ -248,7 +260,11 @@ describe('FlagPersistence tests', () => {
248260
await fpUnderTest.init(context, flags);
249261
await fpUnderTest.upsert(context, 'flagA', { version: 2, flag: flagAv2 });
250262

251-
const contextDataKey = namespaceForContextData(mockPlatform.crypto, TEST_NAMESPACE, context);
263+
const contextDataKey = await namespaceForContextData(
264+
mockPlatform.crypto,
265+
TEST_NAMESPACE,
266+
context,
267+
);
252268

253269
// check memory flag store and persistence
254270
expect(flagStore.get('flagA')?.version).toEqual(2);
@@ -286,12 +302,12 @@ describe('FlagPersistence tests', () => {
286302
flag: makeMockFlag(),
287303
});
288304

289-
const activeContextDataKey = namespaceForContextData(
305+
const activeContextDataKey = await namespaceForContextData(
290306
mockPlatform.crypto,
291307
TEST_NAMESPACE,
292308
activeContext,
293309
);
294-
const inactiveContextDataKey = namespaceForContextData(
310+
const inactiveContextDataKey = await namespaceForContextData(
295311
mockPlatform.crypto,
296312
TEST_NAMESPACE,
297313
inactiveContext,

packages/shared/sdk-client/__tests__/storage/namespaceUtils.test.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,25 @@
11
import { concatNamespacesAndValues } from '../../src/storage/namespaceUtils';
22

3-
const mockHash = (input: string) => `${input}Hashed`;
4-
const noop = (input: string) => input;
3+
const mockHash = async (input: string) => `${input}Hashed`;
4+
const noop = async (input: string) => input;
55

66
describe('concatNamespacesAndValues tests', () => {
77
test('it handles one part', async () => {
8-
const result = concatNamespacesAndValues([{ value: 'LaunchDarkly', transform: mockHash }]);
8+
const result = await concatNamespacesAndValues([
9+
{ value: 'LaunchDarkly', transform: mockHash },
10+
]);
911

1012
expect(result).toEqual('LaunchDarklyHashed');
1113
});
1214

1315
test('it handles empty parts', async () => {
14-
const result = concatNamespacesAndValues([]);
16+
const result = await concatNamespacesAndValues([]);
1517

1618
expect(result).toEqual('');
1719
});
1820

1921
test('it handles many parts', async () => {
20-
const result = concatNamespacesAndValues([
22+
const result = await concatNamespacesAndValues([
2123
{ value: 'LaunchDarkly', transform: mockHash },
2224
{ value: 'ContextKeys', transform: mockHash },
2325
{ value: 'aKind', transform: mockHash },
@@ -27,7 +29,7 @@ describe('concatNamespacesAndValues tests', () => {
2729
});
2830

2931
test('it handles mixture of hashing and no hashing', async () => {
30-
const result = concatNamespacesAndValues([
32+
const result = await concatNamespacesAndValues([
3133
{ value: 'LaunchDarkly', transform: mockHash },
3234
{ value: 'ContextKeys', transform: noop },
3335
{ value: 'aKind', transform: mockHash },

packages/shared/sdk-client/src/context/addAutoEnv.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
} from '@launchdarkly/js-sdk-common';
1313

1414
import Configuration from '../configuration';
15+
import digest from '../crypto/digest';
1516
import { getOrGenerateKey } from '../storage/getOrGenerateKey';
1617
import { namespaceForGeneratedContextKey } from '../storage/namespaceUtils';
1718

@@ -36,10 +37,10 @@ export const toMulti = (c: LDSingleKindContext) => {
3637
* @param config
3738
* @return An LDApplication object with populated key, envAttributesVersion, id and version.
3839
*/
39-
export const addApplicationInfo = (
40+
export const addApplicationInfo = async (
4041
{ crypto, info }: Platform,
4142
{ applicationInfo }: Configuration,
42-
): LDApplication | undefined => {
43+
): Promise<LDApplication | undefined> => {
4344
const { ld_application } = info.platformData();
4445
let app = deepCompact<LDApplication>(ld_application) ?? ({} as LDApplication);
4546
const id = applicationInfo?.id || app?.id;
@@ -58,9 +59,7 @@ export const addApplicationInfo = (
5859
...(versionName ? { versionName } : {}),
5960
};
6061

61-
const hasher = crypto.createHash('sha256');
62-
hasher.update(id);
63-
app.key = hasher.digest('base64');
62+
app.key = await digest(crypto.createHash('sha256').update(id), 'base64');
6463
app.envAttributesVersion = app.envAttributesVersion || defaultAutoEnvSchemaVersion;
6564

6665
return app;
@@ -95,7 +94,7 @@ export const addDeviceInfo = async (platform: Platform) => {
9594

9695
// Check if device has any meaningful data before we return it.
9796
if (Object.keys(device).filter((k) => k !== 'key' && k !== 'envAttributesVersion').length) {
98-
const ldDeviceNamespace = namespaceForGeneratedContextKey('ld_device');
97+
const ldDeviceNamespace = await namespaceForGeneratedContextKey('ld_device');
9998
device.key = await getOrGenerateKey(ldDeviceNamespace, platform);
10099
device.envAttributesVersion = device.envAttributesVersion || defaultAutoEnvSchemaVersion;
101100
return device;
@@ -118,7 +117,7 @@ export const addAutoEnv = async (context: LDContext, platform: Platform, config:
118117
(isSingleKind(context) && context.kind !== 'ld_application') ||
119118
(isMultiKind(context) && !context.ld_application)
120119
) {
121-
ld_application = addApplicationInfo(platform, config);
120+
ld_application = await addApplicationInfo(platform, config);
122121
} else {
123122
config.logger.warn(
124123
'Not adding ld_application environment attributes because it already exists.',

packages/shared/sdk-client/src/context/ensureKey.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ const ensureKeyCommon = async (kind: string, c: LDContextCommon, platform: Platf
3131
const { anonymous, key } = c;
3232

3333
if (anonymous && !key) {
34-
const storageKey = namespaceForAnonymousGeneratedContextKey(kind);
34+
const storageKey = await namespaceForAnonymousGeneratedContextKey(kind);
3535
// This mutates a cloned copy of the original context from ensureyKey so this is safe.
3636
// eslint-disable-next-line no-param-reassign
3737
c.key = await getOrGenerateKey(storageKey, platform);
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { Hasher } from '@launchdarkly/js-sdk-common';
2+
3+
export default async function digest(hasher: Hasher, encoding: string): Promise<string> {
4+
if (hasher.digest) {
5+
return hasher.digest(encoding);
6+
}
7+
if (hasher.asyncDigest) {
8+
return hasher.asyncDigest(encoding);
9+
}
10+
// This represents an error in platform implementation.
11+
throw new Error('Platform must implement digest or asyncDigest');
12+
}

packages/shared/sdk-client/src/flag-manager/FlagManager.ts

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { ItemDescriptor } from './ItemDescriptor';
1515
export default class FlagManager {
1616
private flagStore = new DefaultFlagStore();
1717
private flagUpdater: FlagUpdater;
18-
private flagPersistence: FlagPersistence;
18+
private flagPersistencePromise: Promise<FlagPersistence>;
1919

2020
/**
2121
* @param platform implementation of various platform provided functionality
@@ -31,10 +31,26 @@ export default class FlagManager {
3131
logger: LDLogger,
3232
private readonly timeStamper: () => number = () => Date.now(),
3333
) {
34-
const environmentNamespace = namespaceForEnvironment(platform.crypto, sdkKey);
35-
3634
this.flagUpdater = new FlagUpdater(this.flagStore, logger);
37-
this.flagPersistence = new FlagPersistence(
35+
this.flagPersistencePromise = this.initPersistence(
36+
platform,
37+
sdkKey,
38+
maxCachedContexts,
39+
logger,
40+
timeStamper,
41+
);
42+
}
43+
44+
private async initPersistence(
45+
platform: Platform,
46+
sdkKey: string,
47+
maxCachedContexts: number,
48+
logger: LDLogger,
49+
timeStamper: () => number = () => Date.now(),
50+
): Promise<FlagPersistence> {
51+
const environmentNamespace = await namespaceForEnvironment(platform.crypto, sdkKey);
52+
53+
return new FlagPersistence(
3854
platform,
3955
environmentNamespace,
4056
maxCachedContexts,
@@ -64,22 +80,22 @@ export default class FlagManager {
6480
* Persistence initialization is handled by {@link FlagPersistence}
6581
*/
6682
async init(context: Context, newFlags: { [key: string]: ItemDescriptor }): Promise<void> {
67-
return this.flagPersistence.init(context, newFlags);
83+
return (await this.flagPersistencePromise).init(context, newFlags);
6884
}
6985

7086
/**
7187
* Attempt to update a flag. If the flag is for the wrong context, or
7288
* it is of an older version, then an update will not be performed.
7389
*/
7490
async upsert(context: Context, key: string, item: ItemDescriptor): Promise<boolean> {
75-
return this.flagPersistence.upsert(context, key, item);
91+
return (await this.flagPersistencePromise).upsert(context, key, item);
7692
}
7793

7894
/**
7995
* Asynchronously load cached values from persistence.
8096
*/
8197
async loadCached(context: Context): Promise<boolean> {
82-
return this.flagPersistence.loadCached(context);
98+
return (await this.flagPersistencePromise).loadCached(context);
8399
}
84100

85101
/**

0 commit comments

Comments
 (0)