Skip to content

Commit e763e5d

Browse files
authored
feat: Implement browser crypto and encoding. (#574)
1 parent 9248035 commit e763e5d

File tree

10 files changed

+301
-5
lines changed

10 files changed

+301
-5
lines changed
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// TextEncoder should be part of jsdom, but it is not. So we can import it from node in the tests.
2+
import { TextEncoder } from 'node:util';
3+
4+
import BrowserEncoding from '../../src/platform/BrowserEncoding';
5+
6+
global.TextEncoder = TextEncoder;
7+
8+
it('can base64 a basic ASCII string', () => {
9+
const encoding = new BrowserEncoding();
10+
expect(encoding.btoa('toaster')).toEqual('dG9hc3Rlcg==');
11+
});
12+
13+
it('can base64 a unicode string containing multi-byte character', () => {
14+
const encoding = new BrowserEncoding();
15+
expect(encoding.btoa('✇⽊❽⾵⊚▴ⶊ↺➹≈⋟⚥⤅⊈ⲏⷨ⾭Ⲗ⑲▯ⶋₐℛ⬎⿌🦄')).toEqual(
16+
'4pyH4r2K4p294r614oqa4pa04raK4oa64p654omI4ouf4pql4qSF4oqI4rKP4reo4r6t4rKW4pGy4pav4raL4oKQ4oSb4qyO4r+M8J+mhA==',
17+
);
18+
});
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// TextEncoder should be part of jsdom, but it is not. So we can import it from node in the tests.
2+
import { webcrypto } from 'node:crypto';
3+
import { TextEncoder } from 'node:util';
4+
5+
import BrowserHasher from '../../src/platform/BrowserHasher';
6+
7+
global.TextEncoder = TextEncoder;
8+
9+
// Crypto is injectable as it is also not correctly available with the combination of node and jsdom.
10+
11+
/**
12+
* Test vectors generated using.
13+
* https://www.liavaag.org/English/SHA-Generator/
14+
*/
15+
describe('PlatformHasher', () => {
16+
test('sha256 produces correct base64 output', async () => {
17+
// @ts-ignore
18+
const h = new BrowserHasher(webcrypto, 'sha256');
19+
20+
h.update('test-app-id');
21+
const output = await h.asyncDigest('base64');
22+
23+
expect(output).toEqual('XVm6ZNk6ejx6+IVtL7zfwYwRQ2/ck9+y7FaN32EcudQ=');
24+
});
25+
26+
test('sha256 produces correct hex output', async () => {
27+
// @ts-ignore
28+
const h = new BrowserHasher(webcrypto, 'sha256');
29+
30+
h.update('test-app-id');
31+
const output = await h.asyncDigest('hex');
32+
33+
expect(output).toEqual('5d59ba64d93a7a3c7af8856d2fbcdfc18c11436fdc93dfb2ec568ddf611cb9d4');
34+
});
35+
36+
test('sha1 produces correct base64 output', async () => {
37+
// @ts-ignore
38+
const h = new BrowserHasher(webcrypto, 'sha1');
39+
40+
h.update('test-app-id');
41+
const output = await h.asyncDigest('base64');
42+
43+
expect(output).toEqual('kydC7cRd9+LWbu4Ss/t1FiFmDcs=');
44+
});
45+
46+
test('sha1 produces correct hex output', async () => {
47+
// @ts-ignore
48+
const h = new BrowserHasher(webcrypto, 'sha1');
49+
50+
h.update('test-app-id');
51+
const output = await h.asyncDigest('hex');
52+
53+
expect(output).toEqual('932742edc45df7e2d66eee12b3fb751621660dcb');
54+
});
55+
56+
test('unsupported hash algorithm', async () => {
57+
expect(() => {
58+
// @ts-ignore
59+
// eslint-disable-next-line no-new
60+
new BrowserHasher(webcrypto, 'sha512');
61+
}).toThrow(/Algorithm is not supported/i);
62+
});
63+
64+
test('unsupported output algorithm', async () => {
65+
await expect(async () => {
66+
// @ts-ignore
67+
const h = new BrowserHasher(webcrypto, 'sha256');
68+
h.update('test-app-id');
69+
await h.asyncDigest('base122');
70+
}).rejects.toThrow(/Encoding is not supported/i);
71+
});
72+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/* eslint-disable no-bitwise */
2+
import { fallbackUuidV4, formatDataAsUuidV4 } from '../../src/platform/randomUuidV4';
3+
4+
it('formats conformant UUID', () => {
5+
// For this test we remove the random component and just inspect the variant and version.
6+
const idA = formatDataAsUuidV4(Array(16).fill(0x00));
7+
const idB = formatDataAsUuidV4(Array(16).fill(0xff));
8+
const idC = fallbackUuidV4();
9+
10+
// 32 characters and 4 dashes
11+
expect(idC).toHaveLength(36);
12+
const versionA = idA[14];
13+
const versionB = idB[14];
14+
const versionC = idB[14];
15+
16+
expect(versionA).toEqual('4');
17+
expect(versionB).toEqual('4');
18+
expect(versionC).toEqual('4');
19+
20+
// Keep only the top 2 bits.
21+
const specifierA = parseInt(idA[19], 16) & 0xc;
22+
const specifierB = parseInt(idB[19], 16) & 0xc;
23+
const specifierC = parseInt(idC[19], 16) & 0xc;
24+
25+
// bit 6 should be 0 and bit 8 should be one, which is 0x8
26+
expect(specifierA).toEqual(0x8);
27+
expect(specifierB).toEqual(0x8);
28+
expect(specifierC).toEqual(0x8);
29+
});

packages/sdk/browser/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,15 @@
2727
],
2828
"scripts": {
2929
"clean": "rimraf dist",
30-
"build": "vite build",
30+
"build": "tsc --noEmit && vite build",
3131
"lint": "eslint . --ext .ts,.tsx",
3232
"prettier": "prettier --write '**/*.@(js|ts|tsx|json|css)' --ignore-path ../../../.prettierignore",
3333
"test": "jest",
3434
"coverage": "yarn test --coverage",
3535
"check": "yarn prettier && yarn lint && yarn build && yarn test"
3636
},
3737
"dependencies": {
38-
"@launchdarkly/js-client-sdk-common": "1.5.0"
38+
"@launchdarkly/js-client-sdk-common": "1.7.0"
3939
},
4040
"devDependencies": {
4141
"@launchdarkly/private-js-mocks": "0.0.1",
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { Crypto } from '@launchdarkly/js-client-sdk-common';
2+
3+
import BrowserHasher from './BrowserHasher';
4+
import randomUuidV4 from './randomUuidV4';
5+
6+
export default class BrowserCrypto implements Crypto {
7+
createHash(algorithm: string): BrowserHasher {
8+
return new BrowserHasher(window.crypto, algorithm);
9+
}
10+
11+
randomUUID(): string {
12+
return randomUuidV4();
13+
}
14+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Encoding } from '@launchdarkly/js-client-sdk-common';
2+
3+
function bytesToBase64(bytes: Uint8Array) {
4+
const binString = Array.from(bytes, (byte) => String.fromCodePoint(byte)).join('');
5+
return btoa(binString);
6+
}
7+
8+
/**
9+
* Implementation Note: This btoa handles unicode characters, which the base btoa in the browser
10+
* does not.
11+
* Background: https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem
12+
*/
13+
14+
export default class BrowserEncoding implements Encoding {
15+
btoa(data: string): string {
16+
return bytesToBase64(new TextEncoder().encode(data));
17+
}
18+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { Hasher } from '@launchdarkly/js-client-sdk-common';
2+
3+
export default class BrowserHasher implements Hasher {
4+
private data: string[] = [];
5+
private algorithm: string;
6+
constructor(
7+
private readonly webcrypto: Crypto,
8+
algorithm: string,
9+
) {
10+
switch (algorithm) {
11+
case 'sha1':
12+
this.algorithm = 'SHA-1';
13+
break;
14+
case 'sha256':
15+
this.algorithm = 'SHA-256';
16+
break;
17+
default:
18+
throw new Error(`Algorithm is not supported ${algorithm}`);
19+
}
20+
}
21+
22+
async asyncDigest(encoding: string): Promise<string> {
23+
const combinedData = this.data.join('');
24+
const encoded = new TextEncoder().encode(combinedData);
25+
const digestedBuffer = await this.webcrypto.subtle.digest(this.algorithm, encoded);
26+
switch (encoding) {
27+
case 'base64':
28+
return btoa(String.fromCharCode(...new Uint8Array(digestedBuffer)));
29+
case 'hex':
30+
// Convert the buffer to an array of uint8 values, then convert each of those to hex.
31+
// The map function on a Uint8Array directly only maps to other Uint8Arrays.
32+
return [...new Uint8Array(digestedBuffer)]
33+
.map((val) => val.toString(16).padStart(2, '0'))
34+
.join('');
35+
default:
36+
throw new Error(`Encoding is not supported ${encoding}`);
37+
}
38+
}
39+
40+
update(data: string): Hasher {
41+
this.data.push(data);
42+
return this as Hasher;
43+
}
44+
}

packages/sdk/browser/src/platform/BrowserPlatform.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
import {
2+
Crypto,
3+
/* platform */
24
LDOptions,
35
Storage,
4-
/* platform */
56
} from '@launchdarkly/js-client-sdk-common';
67

8+
import BrowserCrypto from './BrowserCrypto';
79
import LocalStorage, { isLocalStorageSupported } from './LocalStorage';
810

911
export default class BrowserPlatform /* implements platform.Platform */ {
1012
// encoding?: Encoding;
1113
// info: Info;
1214
// fileSystem?: Filesystem;
13-
// crypto: Crypto;
15+
crypto: Crypto = new BrowserCrypto();
1416
// requests: Requests;
1517
storage?: Storage;
1618

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// The implementation in this file generates UUIDs in v4 format and is suitable
2+
// for use as a UUID in LaunchDarkly events. It is not a rigorous implementation.
3+
4+
// It uses crypto.randomUUID when available.
5+
// If crypto.randomUUID is not available, then it uses random values and forms
6+
// the UUID itself.
7+
// When possible it uses crypto.getRandomValues, but it can use Math.random
8+
// if crypto.getRandomValues is not available.
9+
10+
// UUIDv4 Struct definition.
11+
// https://www.rfc-archive.org/getrfc.php?rfc=4122
12+
// Appendix A. Appendix A - Sample Implementation
13+
const timeLow = {
14+
start: 0,
15+
end: 3,
16+
};
17+
const timeMid = {
18+
start: 4,
19+
end: 5,
20+
};
21+
const timeHiAndVersion = {
22+
start: 6,
23+
end: 7,
24+
};
25+
const clockSeqHiAndReserved = {
26+
start: 8,
27+
end: 8,
28+
};
29+
const clockSeqLow = {
30+
start: 9,
31+
end: 9,
32+
};
33+
const nodes = {
34+
start: 10,
35+
end: 15,
36+
};
37+
38+
function getRandom128bit(): number[] {
39+
if (crypto && crypto.getRandomValues) {
40+
const typedArray = new Uint8Array(16);
41+
crypto.getRandomValues(typedArray);
42+
return [...typedArray.values()];
43+
}
44+
const values = [];
45+
for (let index = 0; index < 16; index += 1) {
46+
// Math.random is 0-1 with inclusive min and exclusive max.
47+
values.push(Math.floor(Math.random() * 256));
48+
}
49+
return values;
50+
}
51+
52+
function hex(bytes: number[], range: { start: number; end: number }): string {
53+
let strVal = '';
54+
for (let index = range.start; index <= range.end; index += 1) {
55+
strVal += bytes[index].toString(16).padStart(2, '0');
56+
}
57+
return strVal;
58+
}
59+
60+
/**
61+
* Given a list of 16 random bytes generate a UUID in v4 format.
62+
*
63+
* Note: The input bytes are modified to conform to the requirements of UUID v4.
64+
*
65+
* @param bytes A list of 16 bytes.
66+
* @returns A UUID v4 string.
67+
*/
68+
export function formatDataAsUuidV4(bytes: number[]): string {
69+
// https://www.rfc-archive.org/getrfc.php?rfc=4122
70+
// 4.4. Algorithms for Creating a UUID from Truly Random or
71+
// Pseudo-Random Numbers
72+
73+
// Set the two most significant bits (bits 6 and 7) of the clock_seq_hi_and_reserved to zero and
74+
// one, respectively.
75+
// eslint-disable-next-line no-bitwise, no-param-reassign
76+
bytes[clockSeqHiAndReserved.start] = (bytes[clockSeqHiAndReserved.start] | 0x80) & 0xbf;
77+
// Set the four most significant bits (bits 12 through 15) of the time_hi_and_version field to
78+
// the 4-bit version number from Section 4.1.3.
79+
// eslint-disable-next-line no-bitwise, no-param-reassign
80+
bytes[timeHiAndVersion.start] = (bytes[timeHiAndVersion.start] & 0x0f) | 0x40;
81+
82+
return (
83+
`${hex(bytes, timeLow)}-${hex(bytes, timeMid)}-${hex(bytes, timeHiAndVersion)}-` +
84+
`${hex(bytes, clockSeqHiAndReserved)}${hex(bytes, clockSeqLow)}-${hex(bytes, nodes)}`
85+
);
86+
}
87+
88+
export function fallbackUuidV4(): string {
89+
const bytes = getRandom128bit();
90+
return formatDataAsUuidV4(bytes);
91+
}
92+
93+
export default function randomUuidV4(): string {
94+
if (typeof crypto !== undefined && typeof crypto.randomUUID === 'function') {
95+
return crypto.randomUUID();
96+
}
97+
98+
return fallbackUuidV4();
99+
}

packages/sdk/browser/tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"declaration": true,
55
"declarationMap": true,
66
"jsx": "react-jsx",
7-
"lib": ["es6", "dom"],
7+
"lib": ["ES2017", "dom"],
88
"module": "ES6",
99
"moduleResolution": "node",
1010
"noImplicitOverride": true,

0 commit comments

Comments
 (0)