From e763e5d2e53329c0f86b93544af85ca7a94e7936 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Mon, 9 Sep 2024 13:54:33 -0700 Subject: [PATCH] feat: Implement browser crypto and encoding. (#574) --- .../platform/BrowserEncoding.test.ts | 18 ++++ .../__tests__/platform/BrowserHasher.test.ts | 72 ++++++++++++++ .../__tests__/platform/randomUuidV4.test.ts | 29 ++++++ packages/sdk/browser/package.json | 4 +- .../sdk/browser/src/platform/BrowserCrypto.ts | 14 +++ .../browser/src/platform/BrowserEncoding.ts | 18 ++++ .../sdk/browser/src/platform/BrowserHasher.ts | 44 +++++++++ .../browser/src/platform/BrowserPlatform.ts | 6 +- .../sdk/browser/src/platform/randomUuidV4.ts | 99 +++++++++++++++++++ packages/sdk/browser/tsconfig.json | 2 +- 10 files changed, 301 insertions(+), 5 deletions(-) create mode 100644 packages/sdk/browser/__tests__/platform/BrowserEncoding.test.ts create mode 100644 packages/sdk/browser/__tests__/platform/BrowserHasher.test.ts create mode 100644 packages/sdk/browser/__tests__/platform/randomUuidV4.test.ts create mode 100644 packages/sdk/browser/src/platform/BrowserCrypto.ts create mode 100644 packages/sdk/browser/src/platform/BrowserEncoding.ts create mode 100644 packages/sdk/browser/src/platform/BrowserHasher.ts create mode 100644 packages/sdk/browser/src/platform/randomUuidV4.ts diff --git a/packages/sdk/browser/__tests__/platform/BrowserEncoding.test.ts b/packages/sdk/browser/__tests__/platform/BrowserEncoding.test.ts new file mode 100644 index 000000000..a24865609 --- /dev/null +++ b/packages/sdk/browser/__tests__/platform/BrowserEncoding.test.ts @@ -0,0 +1,18 @@ +// TextEncoder should be part of jsdom, but it is not. So we can import it from node in the tests. +import { TextEncoder } from 'node:util'; + +import BrowserEncoding from '../../src/platform/BrowserEncoding'; + +global.TextEncoder = TextEncoder; + +it('can base64 a basic ASCII string', () => { + const encoding = new BrowserEncoding(); + expect(encoding.btoa('toaster')).toEqual('dG9hc3Rlcg=='); +}); + +it('can base64 a unicode string containing multi-byte character', () => { + const encoding = new BrowserEncoding(); + expect(encoding.btoa('✇⽊❽⾵⊚▴ⶊ↺➹≈⋟⚥⤅⊈ⲏⷨ⾭Ⲗ⑲▯ⶋₐℛ⬎⿌🦄')).toEqual( + '4pyH4r2K4p294r614oqa4pa04raK4oa64p654omI4ouf4pql4qSF4oqI4rKP4reo4r6t4rKW4pGy4pav4raL4oKQ4oSb4qyO4r+M8J+mhA==', + ); +}); diff --git a/packages/sdk/browser/__tests__/platform/BrowserHasher.test.ts b/packages/sdk/browser/__tests__/platform/BrowserHasher.test.ts new file mode 100644 index 000000000..cb143f119 --- /dev/null +++ b/packages/sdk/browser/__tests__/platform/BrowserHasher.test.ts @@ -0,0 +1,72 @@ +// TextEncoder should be part of jsdom, but it is not. So we can import it from node in the tests. +import { webcrypto } from 'node:crypto'; +import { TextEncoder } from 'node:util'; + +import BrowserHasher from '../../src/platform/BrowserHasher'; + +global.TextEncoder = TextEncoder; + +// Crypto is injectable as it is also not correctly available with the combination of node and jsdom. + +/** + * Test vectors generated using. + * https://www.liavaag.org/English/SHA-Generator/ + */ +describe('PlatformHasher', () => { + test('sha256 produces correct base64 output', async () => { + // @ts-ignore + const h = new BrowserHasher(webcrypto, 'sha256'); + + h.update('test-app-id'); + const output = await h.asyncDigest('base64'); + + expect(output).toEqual('XVm6ZNk6ejx6+IVtL7zfwYwRQ2/ck9+y7FaN32EcudQ='); + }); + + test('sha256 produces correct hex output', async () => { + // @ts-ignore + const h = new BrowserHasher(webcrypto, 'sha256'); + + h.update('test-app-id'); + const output = await h.asyncDigest('hex'); + + expect(output).toEqual('5d59ba64d93a7a3c7af8856d2fbcdfc18c11436fdc93dfb2ec568ddf611cb9d4'); + }); + + test('sha1 produces correct base64 output', async () => { + // @ts-ignore + const h = new BrowserHasher(webcrypto, 'sha1'); + + h.update('test-app-id'); + const output = await h.asyncDigest('base64'); + + expect(output).toEqual('kydC7cRd9+LWbu4Ss/t1FiFmDcs='); + }); + + test('sha1 produces correct hex output', async () => { + // @ts-ignore + const h = new BrowserHasher(webcrypto, 'sha1'); + + h.update('test-app-id'); + const output = await h.asyncDigest('hex'); + + expect(output).toEqual('932742edc45df7e2d66eee12b3fb751621660dcb'); + }); + + test('unsupported hash algorithm', async () => { + expect(() => { + // @ts-ignore + // eslint-disable-next-line no-new + new BrowserHasher(webcrypto, 'sha512'); + }).toThrow(/Algorithm is not supported/i); + }); + + test('unsupported output algorithm', async () => { + await expect(async () => { + // @ts-ignore + const h = new BrowserHasher(webcrypto, 'sha256'); + h.update('test-app-id'); + await h.asyncDigest('base122'); + }).rejects.toThrow(/Encoding is not supported/i); + }); +}); diff --git a/packages/sdk/browser/__tests__/platform/randomUuidV4.test.ts b/packages/sdk/browser/__tests__/platform/randomUuidV4.test.ts new file mode 100644 index 000000000..03ec22d12 --- /dev/null +++ b/packages/sdk/browser/__tests__/platform/randomUuidV4.test.ts @@ -0,0 +1,29 @@ +/* eslint-disable no-bitwise */ +import { fallbackUuidV4, formatDataAsUuidV4 } from '../../src/platform/randomUuidV4'; + +it('formats conformant UUID', () => { + // For this test we remove the random component and just inspect the variant and version. + const idA = formatDataAsUuidV4(Array(16).fill(0x00)); + const idB = formatDataAsUuidV4(Array(16).fill(0xff)); + const idC = fallbackUuidV4(); + + // 32 characters and 4 dashes + expect(idC).toHaveLength(36); + const versionA = idA[14]; + const versionB = idB[14]; + const versionC = idB[14]; + + expect(versionA).toEqual('4'); + expect(versionB).toEqual('4'); + expect(versionC).toEqual('4'); + + // Keep only the top 2 bits. + const specifierA = parseInt(idA[19], 16) & 0xc; + const specifierB = parseInt(idB[19], 16) & 0xc; + const specifierC = parseInt(idC[19], 16) & 0xc; + + // bit 6 should be 0 and bit 8 should be one, which is 0x8 + expect(specifierA).toEqual(0x8); + expect(specifierB).toEqual(0x8); + expect(specifierC).toEqual(0x8); +}); diff --git a/packages/sdk/browser/package.json b/packages/sdk/browser/package.json index 8723526a3..1d1c44388 100644 --- a/packages/sdk/browser/package.json +++ b/packages/sdk/browser/package.json @@ -27,7 +27,7 @@ ], "scripts": { "clean": "rimraf dist", - "build": "vite build", + "build": "tsc --noEmit && vite build", "lint": "eslint . --ext .ts,.tsx", "prettier": "prettier --write '**/*.@(js|ts|tsx|json|css)' --ignore-path ../../../.prettierignore", "test": "jest", @@ -35,7 +35,7 @@ "check": "yarn prettier && yarn lint && yarn build && yarn test" }, "dependencies": { - "@launchdarkly/js-client-sdk-common": "1.5.0" + "@launchdarkly/js-client-sdk-common": "1.7.0" }, "devDependencies": { "@launchdarkly/private-js-mocks": "0.0.1", diff --git a/packages/sdk/browser/src/platform/BrowserCrypto.ts b/packages/sdk/browser/src/platform/BrowserCrypto.ts new file mode 100644 index 000000000..e241bc50a --- /dev/null +++ b/packages/sdk/browser/src/platform/BrowserCrypto.ts @@ -0,0 +1,14 @@ +import { Crypto } from '@launchdarkly/js-client-sdk-common'; + +import BrowserHasher from './BrowserHasher'; +import randomUuidV4 from './randomUuidV4'; + +export default class BrowserCrypto implements Crypto { + createHash(algorithm: string): BrowserHasher { + return new BrowserHasher(window.crypto, algorithm); + } + + randomUUID(): string { + return randomUuidV4(); + } +} diff --git a/packages/sdk/browser/src/platform/BrowserEncoding.ts b/packages/sdk/browser/src/platform/BrowserEncoding.ts new file mode 100644 index 000000000..f673c1327 --- /dev/null +++ b/packages/sdk/browser/src/platform/BrowserEncoding.ts @@ -0,0 +1,18 @@ +import { Encoding } from '@launchdarkly/js-client-sdk-common'; + +function bytesToBase64(bytes: Uint8Array) { + const binString = Array.from(bytes, (byte) => String.fromCodePoint(byte)).join(''); + return btoa(binString); +} + +/** + * Implementation Note: This btoa handles unicode characters, which the base btoa in the browser + * does not. + * Background: https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem + */ + +export default class BrowserEncoding implements Encoding { + btoa(data: string): string { + return bytesToBase64(new TextEncoder().encode(data)); + } +} diff --git a/packages/sdk/browser/src/platform/BrowserHasher.ts b/packages/sdk/browser/src/platform/BrowserHasher.ts new file mode 100644 index 000000000..fc46d8d87 --- /dev/null +++ b/packages/sdk/browser/src/platform/BrowserHasher.ts @@ -0,0 +1,44 @@ +import { Hasher } from '@launchdarkly/js-client-sdk-common'; + +export default class BrowserHasher implements Hasher { + private data: string[] = []; + private algorithm: string; + constructor( + private readonly webcrypto: Crypto, + algorithm: string, + ) { + switch (algorithm) { + case 'sha1': + this.algorithm = 'SHA-1'; + break; + case 'sha256': + this.algorithm = 'SHA-256'; + break; + default: + throw new Error(`Algorithm is not supported ${algorithm}`); + } + } + + async asyncDigest(encoding: string): Promise { + const combinedData = this.data.join(''); + const encoded = new TextEncoder().encode(combinedData); + const digestedBuffer = await this.webcrypto.subtle.digest(this.algorithm, encoded); + switch (encoding) { + case 'base64': + return btoa(String.fromCharCode(...new Uint8Array(digestedBuffer))); + case 'hex': + // Convert the buffer to an array of uint8 values, then convert each of those to hex. + // The map function on a Uint8Array directly only maps to other Uint8Arrays. + return [...new Uint8Array(digestedBuffer)] + .map((val) => val.toString(16).padStart(2, '0')) + .join(''); + default: + throw new Error(`Encoding is not supported ${encoding}`); + } + } + + update(data: string): Hasher { + this.data.push(data); + return this as Hasher; + } +} diff --git a/packages/sdk/browser/src/platform/BrowserPlatform.ts b/packages/sdk/browser/src/platform/BrowserPlatform.ts index 419840d5d..8865ebc3c 100644 --- a/packages/sdk/browser/src/platform/BrowserPlatform.ts +++ b/packages/sdk/browser/src/platform/BrowserPlatform.ts @@ -1,16 +1,18 @@ import { + Crypto, + /* platform */ LDOptions, Storage, - /* platform */ } from '@launchdarkly/js-client-sdk-common'; +import BrowserCrypto from './BrowserCrypto'; import LocalStorage, { isLocalStorageSupported } from './LocalStorage'; export default class BrowserPlatform /* implements platform.Platform */ { // encoding?: Encoding; // info: Info; // fileSystem?: Filesystem; - // crypto: Crypto; + crypto: Crypto = new BrowserCrypto(); // requests: Requests; storage?: Storage; diff --git a/packages/sdk/browser/src/platform/randomUuidV4.ts b/packages/sdk/browser/src/platform/randomUuidV4.ts new file mode 100644 index 000000000..0659d58f7 --- /dev/null +++ b/packages/sdk/browser/src/platform/randomUuidV4.ts @@ -0,0 +1,99 @@ +// The implementation in this file generates UUIDs in v4 format and is suitable +// for use as a UUID in LaunchDarkly events. It is not a rigorous implementation. + +// It uses crypto.randomUUID when available. +// If crypto.randomUUID is not available, then it uses random values and forms +// the UUID itself. +// When possible it uses crypto.getRandomValues, but it can use Math.random +// if crypto.getRandomValues is not available. + +// UUIDv4 Struct definition. +// https://www.rfc-archive.org/getrfc.php?rfc=4122 +// Appendix A. Appendix A - Sample Implementation +const timeLow = { + start: 0, + end: 3, +}; +const timeMid = { + start: 4, + end: 5, +}; +const timeHiAndVersion = { + start: 6, + end: 7, +}; +const clockSeqHiAndReserved = { + start: 8, + end: 8, +}; +const clockSeqLow = { + start: 9, + end: 9, +}; +const nodes = { + start: 10, + end: 15, +}; + +function getRandom128bit(): number[] { + if (crypto && crypto.getRandomValues) { + const typedArray = new Uint8Array(16); + crypto.getRandomValues(typedArray); + return [...typedArray.values()]; + } + const values = []; + for (let index = 0; index < 16; index += 1) { + // Math.random is 0-1 with inclusive min and exclusive max. + values.push(Math.floor(Math.random() * 256)); + } + return values; +} + +function hex(bytes: number[], range: { start: number; end: number }): string { + let strVal = ''; + for (let index = range.start; index <= range.end; index += 1) { + strVal += bytes[index].toString(16).padStart(2, '0'); + } + return strVal; +} + +/** + * Given a list of 16 random bytes generate a UUID in v4 format. + * + * Note: The input bytes are modified to conform to the requirements of UUID v4. + * + * @param bytes A list of 16 bytes. + * @returns A UUID v4 string. + */ +export function formatDataAsUuidV4(bytes: number[]): string { + // https://www.rfc-archive.org/getrfc.php?rfc=4122 + // 4.4. Algorithms for Creating a UUID from Truly Random or + // Pseudo-Random Numbers + + // Set the two most significant bits (bits 6 and 7) of the clock_seq_hi_and_reserved to zero and + // one, respectively. + // eslint-disable-next-line no-bitwise, no-param-reassign + bytes[clockSeqHiAndReserved.start] = (bytes[clockSeqHiAndReserved.start] | 0x80) & 0xbf; + // Set the four most significant bits (bits 12 through 15) of the time_hi_and_version field to + // the 4-bit version number from Section 4.1.3. + // eslint-disable-next-line no-bitwise, no-param-reassign + bytes[timeHiAndVersion.start] = (bytes[timeHiAndVersion.start] & 0x0f) | 0x40; + + return ( + `${hex(bytes, timeLow)}-${hex(bytes, timeMid)}-${hex(bytes, timeHiAndVersion)}-` + + `${hex(bytes, clockSeqHiAndReserved)}${hex(bytes, clockSeqLow)}-${hex(bytes, nodes)}` + ); +} + +export function fallbackUuidV4(): string { + const bytes = getRandom128bit(); + return formatDataAsUuidV4(bytes); +} + +export default function randomUuidV4(): string { + if (typeof crypto !== undefined && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + + return fallbackUuidV4(); +} diff --git a/packages/sdk/browser/tsconfig.json b/packages/sdk/browser/tsconfig.json index b1c92fdd9..79420d3d4 100644 --- a/packages/sdk/browser/tsconfig.json +++ b/packages/sdk/browser/tsconfig.json @@ -4,7 +4,7 @@ "declaration": true, "declarationMap": true, "jsx": "react-jsx", - "lib": ["es6", "dom"], + "lib": ["ES2017", "dom"], "module": "ES6", "moduleResolution": "node", "noImplicitOverride": true,