Skip to content

Commit

Permalink
feat: Implement browser crypto and encoding. (#574)
Browse files Browse the repository at this point in the history
  • Loading branch information
kinyoklion authored Sep 9, 2024
1 parent 9248035 commit e763e5d
Show file tree
Hide file tree
Showing 10 changed files with 301 additions and 5 deletions.
18 changes: 18 additions & 0 deletions packages/sdk/browser/__tests__/platform/BrowserEncoding.test.ts
Original file line number Diff line number Diff line change
@@ -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==',
);
});
72 changes: 72 additions & 0 deletions packages/sdk/browser/__tests__/platform/BrowserHasher.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
29 changes: 29 additions & 0 deletions packages/sdk/browser/__tests__/platform/randomUuidV4.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
4 changes: 2 additions & 2 deletions packages/sdk/browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,15 @@
],
"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",
"coverage": "yarn test --coverage",
"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",
Expand Down
14 changes: 14 additions & 0 deletions packages/sdk/browser/src/platform/BrowserCrypto.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
18 changes: 18 additions & 0 deletions packages/sdk/browser/src/platform/BrowserEncoding.ts
Original file line number Diff line number Diff line change
@@ -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));
}
}
44 changes: 44 additions & 0 deletions packages/sdk/browser/src/platform/BrowserHasher.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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;
}
}
6 changes: 4 additions & 2 deletions packages/sdk/browser/src/platform/BrowserPlatform.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
99 changes: 99 additions & 0 deletions packages/sdk/browser/src/platform/randomUuidV4.ts
Original file line number Diff line number Diff line change
@@ -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();
}
2 changes: 1 addition & 1 deletion packages/sdk/browser/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"declaration": true,
"declarationMap": true,
"jsx": "react-jsx",
"lib": ["es6", "dom"],
"lib": ["ES2017", "dom"],
"module": "ES6",
"moduleResolution": "node",
"noImplicitOverride": true,
Expand Down

0 comments on commit e763e5d

Please sign in to comment.