Skip to content

Commit

Permalink
feat: Add platform support for async hashing. (#573)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
kinyoklion authored Sep 9, 2024
1 parent fca4d92 commit 9248035
Show file tree
Hide file tree
Showing 13 changed files with 142 additions and 66 deletions.
21 changes: 18 additions & 3 deletions packages/shared/common/src/api/platform/Crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,19 @@
*/
export interface Hasher {
update(data: string): Hasher;
digest(encoding: string): string;
/**
* Note: All server SDKs MUST implement synchronous digest.
*
* Server SDKs have high performance requirements for bucketing users.
*/
digest?(encoding: string): string;

/**
* Note: Client-side SDKs MUST implement either synchronous or asynchronous digest.
*
* Client SDKs do not have high throughput hashing operations.
*/
asyncDigest?(encoding: string): Promise<string>;
}

/**
Expand All @@ -17,7 +29,7 @@ export interface Hasher {
*
* The has implementation must support digesting to 'hex'.
*/
export interface Hmac extends Hasher {
export interface Hmac {
update(data: string): Hasher;
digest(encoding: string): string;
}
Expand All @@ -27,6 +39,9 @@ export interface Hmac extends Hasher {
*/
export interface Crypto {
createHash(algorithm: string): Hasher;
createHmac(algorithm: string, key: string): Hmac;
/**
* Note: Server SDKs MUST implement createHmac.
*/
createHmac?(algorithm: string, key: string): Hmac;
randomUUID(): string;
}
28 changes: 14 additions & 14 deletions packages/shared/sdk-client/__tests__/context/addAutoEnv.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ describe('automatic environment attributes', () => {
});

describe('addApplicationInfo', () => {
test('add id, version, name, versionName', () => {
test('add id, version, name, versionName', async () => {
config = new Configuration({
applicationInfo: {
id: 'com.from-config.ld',
Expand All @@ -346,7 +346,7 @@ describe('automatic environment attributes', () => {
versionName: 'test-ld-version-name',
},
});
const ldApplication = addApplicationInfo(mockPlatform, config);
const ldApplication = await addApplicationInfo(mockPlatform, config);

expect(ldApplication).toEqual({
envAttributesVersion: '1.0',
Expand All @@ -358,8 +358,8 @@ describe('automatic environment attributes', () => {
});
});

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

expect(ldApplication).toEqual({
envAttributesVersion: '1.0',
Expand All @@ -370,7 +370,7 @@ describe('automatic environment attributes', () => {
});
});

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

const ldApplication = addApplicationInfo(mockPlatform, config);
const ldApplication = await addApplicationInfo(mockPlatform, config);

expect(ldApplication).toEqual({
envAttributesVersion: '1.0',
Expand All @@ -393,15 +393,15 @@ describe('automatic environment attributes', () => {
});
});

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

const ldApplication = addApplicationInfo(mockPlatform, config);
const ldApplication = await addApplicationInfo(mockPlatform, config);

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

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

const ldApplication = addApplicationInfo(mockPlatform, config);
const ldApplication = await addApplicationInfo(mockPlatform, config);

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

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

const ldApplication = addApplicationInfo(mockPlatform, config);
const ldApplication = await addApplicationInfo(mockPlatform, config);

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

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

expect(ldApplication).toBeUndefined();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,12 @@ describe('FlagPersistence tests', () => {

await fpUnderTest.init(context, flags);

const contextDataKey = namespaceForContextData(mockPlatform.crypto, TEST_NAMESPACE, context);
const contextIndexKey = namespaceForContextIndex(TEST_NAMESPACE);
const contextDataKey = await namespaceForContextData(
mockPlatform.crypto,
TEST_NAMESPACE,
context,
);
const contextIndexKey = await namespaceForContextIndex(TEST_NAMESPACE);
expect(await memoryStorage.get(contextIndexKey)).toContain(contextDataKey);
expect(await memoryStorage.get(contextDataKey)).toContain('flagA');
});
Expand Down Expand Up @@ -175,9 +179,17 @@ describe('FlagPersistence tests', () => {
await fpUnderTest.init(context1, flags);
await fpUnderTest.init(context2, flags);

const context1DataKey = namespaceForContextData(mockPlatform.crypto, TEST_NAMESPACE, context1);
const context2DataKey = namespaceForContextData(mockPlatform.crypto, TEST_NAMESPACE, context2);
const contextIndexKey = namespaceForContextIndex(TEST_NAMESPACE);
const context1DataKey = await namespaceForContextData(
mockPlatform.crypto,
TEST_NAMESPACE,
context1,
);
const context2DataKey = await namespaceForContextData(
mockPlatform.crypto,
TEST_NAMESPACE,
context2,
);
const contextIndexKey = await namespaceForContextIndex(TEST_NAMESPACE);

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

await fpUnderTest.init(context, flags);
await fpUnderTest.init(context, flags);
const contextIndexKey = namespaceForContextIndex(TEST_NAMESPACE);
const contextIndexKey = await namespaceForContextIndex(TEST_NAMESPACE);

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

const contextDataKey = namespaceForContextData(mockPlatform.crypto, TEST_NAMESPACE, context);
const contextDataKey = await namespaceForContextData(
mockPlatform.crypto,
TEST_NAMESPACE,
context,
);

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

const activeContextDataKey = namespaceForContextData(
const activeContextDataKey = await namespaceForContextData(
mockPlatform.crypto,
TEST_NAMESPACE,
activeContext,
);
const inactiveContextDataKey = namespaceForContextData(
const inactiveContextDataKey = await namespaceForContextData(
mockPlatform.crypto,
TEST_NAMESPACE,
inactiveContext,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
import { concatNamespacesAndValues } from '../../src/storage/namespaceUtils';

const mockHash = (input: string) => `${input}Hashed`;
const noop = (input: string) => input;
const mockHash = async (input: string) => `${input}Hashed`;
const noop = async (input: string) => input;

describe('concatNamespacesAndValues tests', () => {
test('it handles one part', async () => {
const result = concatNamespacesAndValues([{ value: 'LaunchDarkly', transform: mockHash }]);
const result = await concatNamespacesAndValues([
{ value: 'LaunchDarkly', transform: mockHash },
]);

expect(result).toEqual('LaunchDarklyHashed');
});

test('it handles empty parts', async () => {
const result = concatNamespacesAndValues([]);
const result = await concatNamespacesAndValues([]);

expect(result).toEqual('');
});

test('it handles many parts', async () => {
const result = concatNamespacesAndValues([
const result = await concatNamespacesAndValues([
{ value: 'LaunchDarkly', transform: mockHash },
{ value: 'ContextKeys', transform: mockHash },
{ value: 'aKind', transform: mockHash },
Expand All @@ -27,7 +29,7 @@ describe('concatNamespacesAndValues tests', () => {
});

test('it handles mixture of hashing and no hashing', async () => {
const result = concatNamespacesAndValues([
const result = await concatNamespacesAndValues([
{ value: 'LaunchDarkly', transform: mockHash },
{ value: 'ContextKeys', transform: noop },
{ value: 'aKind', transform: mockHash },
Expand Down
13 changes: 6 additions & 7 deletions packages/shared/sdk-client/src/context/addAutoEnv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from '@launchdarkly/js-sdk-common';

import Configuration from '../configuration';
import digest from '../crypto/digest';
import { getOrGenerateKey } from '../storage/getOrGenerateKey';
import { namespaceForGeneratedContextKey } from '../storage/namespaceUtils';

Expand All @@ -36,10 +37,10 @@ export const toMulti = (c: LDSingleKindContext) => {
* @param config
* @return An LDApplication object with populated key, envAttributesVersion, id and version.
*/
export const addApplicationInfo = (
export const addApplicationInfo = async (
{ crypto, info }: Platform,
{ applicationInfo }: Configuration,
): LDApplication | undefined => {
): Promise<LDApplication | undefined> => {
const { ld_application } = info.platformData();
let app = deepCompact<LDApplication>(ld_application) ?? ({} as LDApplication);
const id = applicationInfo?.id || app?.id;
Expand All @@ -58,9 +59,7 @@ export const addApplicationInfo = (
...(versionName ? { versionName } : {}),
};

const hasher = crypto.createHash('sha256');
hasher.update(id);
app.key = hasher.digest('base64');
app.key = await digest(crypto.createHash('sha256').update(id), 'base64');
app.envAttributesVersion = app.envAttributesVersion || defaultAutoEnvSchemaVersion;

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

// Check if device has any meaningful data before we return it.
if (Object.keys(device).filter((k) => k !== 'key' && k !== 'envAttributesVersion').length) {
const ldDeviceNamespace = namespaceForGeneratedContextKey('ld_device');
const ldDeviceNamespace = await namespaceForGeneratedContextKey('ld_device');
device.key = await getOrGenerateKey(ldDeviceNamespace, platform);
device.envAttributesVersion = device.envAttributesVersion || defaultAutoEnvSchemaVersion;
return device;
Expand All @@ -118,7 +117,7 @@ export const addAutoEnv = async (context: LDContext, platform: Platform, config:
(isSingleKind(context) && context.kind !== 'ld_application') ||
(isMultiKind(context) && !context.ld_application)
) {
ld_application = addApplicationInfo(platform, config);
ld_application = await addApplicationInfo(platform, config);
} else {
config.logger.warn(
'Not adding ld_application environment attributes because it already exists.',
Expand Down
2 changes: 1 addition & 1 deletion packages/shared/sdk-client/src/context/ensureKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const ensureKeyCommon = async (kind: string, c: LDContextCommon, platform: Platf
const { anonymous, key } = c;

if (anonymous && !key) {
const storageKey = namespaceForAnonymousGeneratedContextKey(kind);
const storageKey = await namespaceForAnonymousGeneratedContextKey(kind);
// This mutates a cloned copy of the original context from ensureyKey so this is safe.
// eslint-disable-next-line no-param-reassign
c.key = await getOrGenerateKey(storageKey, platform);
Expand Down
12 changes: 12 additions & 0 deletions packages/shared/sdk-client/src/crypto/digest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Hasher } from '@launchdarkly/js-sdk-common';

export default async function digest(hasher: Hasher, encoding: string): Promise<string> {
if (hasher.digest) {
return hasher.digest(encoding);
}
if (hasher.asyncDigest) {
return hasher.asyncDigest(encoding);
}
// This represents an error in platform implementation.
throw new Error('Platform must implement digest or asyncDigest');
}
30 changes: 23 additions & 7 deletions packages/shared/sdk-client/src/flag-manager/FlagManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { ItemDescriptor } from './ItemDescriptor';
export default class FlagManager {
private flagStore = new DefaultFlagStore();
private flagUpdater: FlagUpdater;
private flagPersistence: FlagPersistence;
private flagPersistencePromise: Promise<FlagPersistence>;

/**
* @param platform implementation of various platform provided functionality
Expand All @@ -31,10 +31,26 @@ export default class FlagManager {
logger: LDLogger,
private readonly timeStamper: () => number = () => Date.now(),
) {
const environmentNamespace = namespaceForEnvironment(platform.crypto, sdkKey);

this.flagUpdater = new FlagUpdater(this.flagStore, logger);
this.flagPersistence = new FlagPersistence(
this.flagPersistencePromise = this.initPersistence(
platform,
sdkKey,
maxCachedContexts,
logger,
timeStamper,
);
}

private async initPersistence(
platform: Platform,
sdkKey: string,
maxCachedContexts: number,
logger: LDLogger,
timeStamper: () => number = () => Date.now(),
): Promise<FlagPersistence> {
const environmentNamespace = await namespaceForEnvironment(platform.crypto, sdkKey);

return new FlagPersistence(
platform,
environmentNamespace,
maxCachedContexts,
Expand Down Expand Up @@ -64,22 +80,22 @@ export default class FlagManager {
* Persistence initialization is handled by {@link FlagPersistence}
*/
async init(context: Context, newFlags: { [key: string]: ItemDescriptor }): Promise<void> {
return this.flagPersistence.init(context, newFlags);
return (await this.flagPersistencePromise).init(context, newFlags);
}

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

/**
* Asynchronously load cached values from persistence.
*/
async loadCached(context: Context): Promise<boolean> {
return this.flagPersistence.loadCached(context);
return (await this.flagPersistencePromise).loadCached(context);
}

/**
Expand Down
Loading

0 comments on commit 9248035

Please sign in to comment.