-
Notifications
You must be signed in to change notification settings - Fork 20
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: Implement browser crypto and encoding. #574
Changes from all commits
c4a67ce
5d11106
2c0a4b1
3215c24
5bdb00c
5fe646c
e7f757a
7bd4258
dba54b2
c12b438
5c36b6e
e1585a9
8053710
232b7ee
6c153b8
21d3db7
5767037
e5b4be8
5506bbe
7c2cf42
f4cb3a8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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==', | ||
); | ||
}); |
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); | ||
}); | ||
}); |
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); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Until we automate releases we need to manually update this or it will used the published version instead of the repo version. We could enable release-please and just not publish. |
||
}, | ||
"devDependencies": { | ||
"@launchdarkly/private-js-mocks": "0.0.1", | ||
|
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(); | ||
} | ||
} |
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)); | ||
} | ||
} |
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; | ||
} | ||
} |
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This code was implemented for error monitoring, but that is still on a feature branch. I think, after the client is working, we need to make a |
||
// 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(); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,7 +4,7 @@ | |
"declaration": true, | ||
"declarationMap": true, | ||
"jsx": "react-jsx", | ||
"lib": ["es6", "dom"], | ||
"lib": ["ES2017", "dom"], | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think that es2017 should be an acceptable target, but we can lower it if we need to. If we do lower it, then we would need to remove the TextEncoder usage. |
||
"module": "ES6", | ||
"moduleResolution": "node", | ||
"noImplicitOverride": true, | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Need to have
tsc --noEmit
to validate the typescriptvite
just builds it and doesn't type check.