From e0cc13cfd232d9a1c76e3876cf26504814f0fa8e Mon Sep 17 00:00:00 2001 From: Tom Hacohen Date: Thu, 30 Nov 2017 13:46:46 +0000 Subject: [PATCH] Initial code import of the etesync encryption and service module This will be a small library in the end, but at the moment it's in this repo for convenience. It includes the etesync service, crypto and tests to cover them. The tests require a running debug etesync server. To create one, just create a server from: https://github.com/etesync/server-skeleton/ Set DEBUG to True, and create a test user with the credentials that are listed in the test files. --- package.json | 13 +- src/App.tsx | 22 ++- src/api/Constants.tsx | 1 + src/api/Crypto.test.tsx | 29 +++ src/api/Crypto.tsx | 90 ++++++++++ src/api/EteSync.test.tsx | 123 +++++++++++++ src/api/EteSync.tsx | 368 ++++++++++++++++++++++++++++++++++++++ src/api/Helpers.tsx | 11 ++ src/api/TestConstants.tsx | 6 + yarn.lock | 100 ++++++++++- 10 files changed, 749 insertions(+), 14 deletions(-) create mode 100644 src/api/Constants.tsx create mode 100644 src/api/Crypto.test.tsx create mode 100644 src/api/Crypto.tsx create mode 100644 src/api/EteSync.test.tsx create mode 100644 src/api/EteSync.tsx create mode 100644 src/api/Helpers.tsx create mode 100644 src/api/TestConstants.tsx diff --git a/package.json b/package.json index edcd2ce..70b7435 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,13 @@ "version": "0.1.0", "private": true, "dependencies": { + "isomorphic-fetch": "^2.1.1", "react": "^16.2.0", "react-dom": "^16.2.0", - "react-scripts-ts": "2.8.0" + "react-router-dom": "^4.2.2", + "react-scripts-ts": "2.8.0", + "sjcl": "^1.0.7", + "urijs": "^1.16.1" }, "scripts": { "start": "react-scripts-ts start", @@ -14,9 +18,14 @@ "eject": "react-scripts-ts eject" }, "devDependencies": { + "@types/isomorphic-fetch": "^0.0.34", "@types/jest": "^21.1.8", "@types/node": "^8.0.53", "@types/react": "^16.0.25", - "@types/react-dom": "^16.0.3" + "@types/react-dom": "^16.0.3", + "@types/react-router": "^4.0.19", + "@types/react-router-dom": "^4.2.3", + "@types/sjcl": "^1.0.28", + "@types/urijs": "^1.15.34" } } diff --git a/src/App.tsx b/src/App.tsx index a8de509..30d7516 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,20 +1,26 @@ import * as React from 'react'; +import { Router } from 'react-router'; import './App.css'; +import createBrowserHistory from 'history/createBrowserHistory'; +const customHistory = createBrowserHistory(); + const logo = require('./logo.svg'); class App extends React.Component { render() { return ( -
-
- logo -

Welcome to React

+ +
+
+ logo +

Welcome to React

+
+

+ To get started, edit src/App.tsx and save to reload. +

-

- To get started, edit src/App.tsx and save to reload. -

-
+ ); } } diff --git a/src/api/Constants.tsx b/src/api/Constants.tsx new file mode 100644 index 0000000..8a308aa --- /dev/null +++ b/src/api/Constants.tsx @@ -0,0 +1 @@ +export const CURRENT_VERSION = 2; diff --git a/src/api/Crypto.test.tsx b/src/api/Crypto.test.tsx new file mode 100644 index 0000000..0f27077 --- /dev/null +++ b/src/api/Crypto.test.tsx @@ -0,0 +1,29 @@ +import { CryptoManager, deriveKey } from './Crypto'; +import { USER, PASSWORD, keyBase64 } from './TestConstants'; + +import { stringToByteArray } from './Helpers'; + +it('Derive key', () => { + const derived = deriveKey(USER, PASSWORD); + expect(derived).toBe(keyBase64); +}); + +it('Symmetric encryption v1', () => { + const cryptoManager = new CryptoManager(keyBase64, 'TestSaltShouldBeJournalId', 1); + const clearText = 'This Is Some Test Cleartext.'; + const cipher = cryptoManager.encrypt(clearText); + expect(clearText).toBe(cryptoManager.decrypt(cipher)); + + const expected = 'Lz+HUFzh1HdjxuGdQrBwBG1IzHT0ug6mO8fwePSbXtc='; + expect(expected).toBe(cryptoManager.hmac64(stringToByteArray('Some test data'))); +}); + +it('Symmetric encryption v2', () => { + const cryptoManager = new CryptoManager(keyBase64, 'TestSaltShouldBeJournalId', 2); + const clearText = 'This Is Some Test Cleartext.'; + const cipher = cryptoManager.encrypt(clearText); + expect(clearText).toBe(cryptoManager.decrypt(cipher)); + + const expected = 'XQ/A0gentOaE98R9wzf3zEIAHj4OH1GF8J4C6JiJupo='; + expect(expected).toBe(cryptoManager.hmac64(stringToByteArray('Some test data'))); +}); diff --git a/src/api/Crypto.tsx b/src/api/Crypto.tsx new file mode 100644 index 0000000..72021f4 --- /dev/null +++ b/src/api/Crypto.tsx @@ -0,0 +1,90 @@ +import * as sjcl from 'sjcl'; + +import * as Constants from './Constants'; +import { byte, base64 } from './Helpers'; + +(sjcl as any).beware['CBC mode is dangerous because it doesn\'t protect message integrity.'](); + +export const HMAC_SIZE_BYTES = 32; + +export function deriveKey(salt: string, password: string): string { + const keySize = 190 * 8; + + return sjcl.codec.base64.fromBits((sjcl.misc as any).scrypt(password, salt, 16384, 8, 1, keySize)); +} + +function hmac256(salt: sjcl.BitArray, key: sjcl.BitArray) { + let hmac = new sjcl.misc.hmac(salt); + return hmac.encrypt(key); +} + +export class CryptoManager { + version: number; + key: sjcl.BitArray; + cipherKey: sjcl.BitArray; + hmacKey: sjcl.BitArray; + + cipherWords = 4; + + constructor(_keyBase64: base64, salt: string, version: number = Constants.CURRENT_VERSION) { + this.version = version; + let key = sjcl.codec.base64.toBits(_keyBase64); + // FIXME: Clean up all exeptions + if (version > Constants.CURRENT_VERSION) { + throw new Error('VersionTooNewException'); + } else if (version === 1) { + this.key = key; + } else { + this.key = hmac256(sjcl.codec.utf8String.toBits(salt), key); + } + + this._updateDerivedKeys(); + } + + _updateDerivedKeys() { + this.cipherKey = hmac256(sjcl.codec.utf8String.toBits('aes'), this.key); + this.hmacKey = hmac256(sjcl.codec.utf8String.toBits('hmac'), this.key); + } + + encrypt(content: string): byte[] { + // FIXME!!! Use a fixed size iv for testing + // switch to: sjcl.random.randomWords(this.cipherWords); + const iv = [1, 1, 1, 1]; + + let prp = new sjcl.cipher.aes(this.cipherKey); + let cipherText = sjcl.mode.cbc.encrypt(prp, sjcl.codec.utf8String.toBits(content), iv); + return sjcl.codec.bytes.fromBits(iv.concat(cipherText)); + } + + decrypt(content: byte[]): string { + let cipherText = sjcl.codec.bytes.toBits(content); + const iv = cipherText.splice(0, this.cipherWords); + + let prp = new sjcl.cipher.aes(this.cipherKey); + let clearText = sjcl.mode.cbc.decrypt(prp, cipherText, iv); + return sjcl.codec.utf8String.fromBits(clearText); + } + + hmac(content: byte[]): byte[] { + return sjcl.codec.bytes.fromBits(this.hmacBase(content)); + } + + hmac64(content: byte[]): base64 { + return sjcl.codec.base64.fromBits(this.hmacBase(content)); + } + + hmacHex(content: byte[]): string { + return sjcl.codec.hex.fromBits(this.hmacBase(content)); + } + + private hmacBase(_content: byte[]): sjcl.BitArray { + let content; + if (this.version === 1) { + content = sjcl.codec.bytes.toBits(_content); + } else { + content = sjcl.codec.bytes.toBits(_content.concat([this.version])); + } + + return hmac256(this.hmacKey, content); + } +} diff --git a/src/api/EteSync.test.tsx b/src/api/EteSync.test.tsx new file mode 100644 index 0000000..c8472a8 --- /dev/null +++ b/src/api/EteSync.test.tsx @@ -0,0 +1,123 @@ +import * as EteSync from './EteSync'; + +import * as fetch from 'isomorphic-fetch'; +import * as sjcl from 'sjcl'; + +const testApiBase = 'http://localhost:8000'; + +import { USER, PASSWORD, keyBase64 } from './TestConstants'; + +let credentials: EteSync.Credentials; + +beforeEach(async () => { + let authenticator = new EteSync.Authenticator(testApiBase); + const authToken = await authenticator.getAuthToken(USER, PASSWORD); + + credentials = new EteSync.Credentials(USER, authToken); + + await fetch(testApiBase + '/reset/', { + method: 'post', + headers: { 'Authorization': 'Token ' + credentials.authToken }, + }); +}); + +it('Simple sync', async () => { + let journalManager = new EteSync.JournalManager(credentials, testApiBase); + let journals = await journalManager.list(); + expect(journals.length).toBe(0); + + const uid1 = sjcl.codec.hex.fromBits(sjcl.hash.sha256.hash('id1')); + const cryptoManager = new EteSync.CryptoManager(keyBase64, USER); + const info1 = new EteSync.CollectionInfo({uid: uid1, content: 'test', displayName: 'Dislpay 1'}); + let journal = new EteSync.Journal(); + journal.setInfo(cryptoManager, info1); + + await expect(journalManager.create(journal)).resolves.toBeDefined(); + + // Uid clash + await expect(journalManager.create(journal)).rejects.toBeInstanceOf(EteSync.HTTPError); + + journals = await journalManager.list(); + expect(journals.length).toBe(1); + expect(journals[0].uid).toBe(journal.uid); + + // Update + let info2 = new EteSync.CollectionInfo(info1); + info2.displayName = 'Display 2'; + + journal.setInfo(cryptoManager, info2); + await expect(journalManager.update(journal)).resolves.toBeDefined(); + + journals = await journalManager.list(); + expect(journals.length).toBe(1); + + expect(journals[0].getInfo(cryptoManager).displayName).toBe(info2.displayName); + + // Delete + await expect(journalManager.delete(journal)).resolves.toBeDefined(); + journals = await journalManager.list(); + expect(journals.length).toBe(0); +}); + +it('Journal Entry sync', async () => { + let journalManager = new EteSync.JournalManager(credentials, testApiBase); + + const uid1 = sjcl.codec.hex.fromBits(sjcl.hash.sha256.hash('id1')); + const cryptoManager = new EteSync.CryptoManager(keyBase64, USER); + const info1 = new EteSync.CollectionInfo({uid: uid1, content: 'test', displayName: 'Dislpay 1'}); + let journal = new EteSync.Journal(); + journal.setInfo(cryptoManager, info1); + + await expect(journalManager.create(journal)).resolves.toBeDefined(); + + let entryManager = new EteSync.EntryManager(credentials, testApiBase, journal.uid); + + let entries = await entryManager.list(null); + expect(entries.length).toBe(0); + + const syncEntry = new EteSync.SyncEntry({content: 'bla'}); + let prevUid = null; + let entry = new EteSync.Entry(); + entry.setSyncEntry(cryptoManager, syncEntry, prevUid); + + entries = [entry]; + await expect(entryManager.create(entries, prevUid)).resolves.toBeDefined(); + prevUid = entry.uid; + + let entry2 = new EteSync.Entry(); + entry2.setSyncEntry(cryptoManager, syncEntry, prevUid); + + // Try to push good entries with a bad prevUid + entries = [entry2]; + await expect(entryManager.create(entries, null)).rejects.toBeInstanceOf(EteSync.HTTPError); + + // Second try with good prevUid + await expect(entryManager.create(entries, prevUid)).resolves.toBeDefined(); + prevUid = entry2.uid; + + // Check last works: + entries = await entryManager.list(null); + expect(entries.length).toBe(2); + + entries = await entryManager.list(entry.uid); + expect(entries.length).toBe(1); + + entries = await entryManager.list(entry2.uid); + expect(entries.length).toBe(0); + + // Corrupt the journal and verify we get it: + entry2 = new EteSync.Entry(); + entry2.setSyncEntry(cryptoManager, syncEntry, 'somebaduid'); + entries = [entry2]; + await expect(entryManager.create(entries, prevUid)).resolves.toBeDefined(); + + entries = await entryManager.list(null); + + expect(() => { + let prev = null; + for (let ent of entries) { + expect(ent.getSyncEntry(cryptoManager, prev)).toBeDefined(); + prev = ent.uid; + } + }).toThrowError(); +}); diff --git a/src/api/EteSync.tsx b/src/api/EteSync.tsx new file mode 100644 index 0000000..44797c5 --- /dev/null +++ b/src/api/EteSync.tsx @@ -0,0 +1,368 @@ +import * as sjcl from 'sjcl'; +import * as URI from 'urijs'; + +import * as fetch from 'isomorphic-fetch'; + +import { byte, base64, stringToByteArray } from './Helpers'; +import { CryptoManager, HMAC_SIZE_BYTES } from './Crypto'; +export { CryptoManager } from './Crypto'; + +class ExtendableError extends Error { + constructor(message: any) { + super(message); + Object.setPrototypeOf(this, ExtendableError.prototype); + this.name = this.constructor.name; + this.stack = (new Error(message)).stack; + } +} + +export class HTTPError extends ExtendableError { + constructor(message: any) { + super(message); + Object.setPrototypeOf(this, HTTPError.prototype); + this.name = this.constructor.name; + } +} + +export class IntegrityError extends ExtendableError { + constructor(message: any) { + super(message); + Object.setPrototypeOf(this, IntegrityError.prototype); + this.name = this.constructor.name; + } +} + +// FIXME: Make secure + types +function CastJson(json: any, to: any) { + return Object.assign(to, json); +} + +function hmacToHex(hmac: byte[]): string { + return sjcl.codec.hex.fromBits(sjcl.codec.bytes.toBits(hmac)); +} + +export class Credentials { + email: string; + authToken: string; + + constructor(email: string, authToken: string) { + this.email = email; + this.authToken = authToken; + } +} + +export class CollectionInfo { + uid: string; + version: number; + type: string; + displayName: string; + description: string; + color: number; + + constructor(json?: any) { + CastJson(json, this); + } +} + +class BaseJournal { + uid: string; + protected _encrypted: byte[]; + protected _content?: string; + + a(a: T) { /* Silence T unused */ } + + deserialize(uid: string, content: base64) { + this.uid = uid; + this._encrypted = sjcl.codec.bytes.fromBits(sjcl.codec.base64.toBits(content)); + this._content = undefined; + } + + serialize(): {uid: string, content: base64} { + return { + uid: this.uid, + content: sjcl.codec.base64.fromBits(sjcl.codec.bytes.toBits(this._encrypted)), + }; + } + + protected verifyBase(hmac: byte[], calculated: byte[]) { + if (!this.hmacEqual(hmac, calculated)) { + throw new IntegrityError('Bad HMAC. ' + hmacToHex(hmac) + ' != ' + hmacToHex(calculated)); + } + } + + private hmacEqual(hmac: byte[], calculated: byte[]) { + return (hmac.length === calculated.length) && + (hmac.every((v, i) => v === calculated[i])); + } +} + +export class Journal extends BaseJournal { + setInfo(cryptoManager: CryptoManager, info: CollectionInfo) { + this.uid = info.uid; + this._content = JSON.stringify(info); + const encrypted = cryptoManager.encrypt(this._content); + this._encrypted = this.calculateHmac(cryptoManager, encrypted).concat(encrypted); + } + + getInfo(cryptoManager: CryptoManager): CollectionInfo { + if (this._content === undefined) { + this._content = JSON.parse(cryptoManager.decrypt(this.encryptedContent())); + } + + this.verify(cryptoManager); + + return new CollectionInfo(this._content); + } + + calculateHmac(cryptoManager: CryptoManager, encrypted: byte[]): byte[] { + let prefix = stringToByteArray(this.uid); + return cryptoManager.hmac(prefix.concat(encrypted)); + } + + verify(cryptoManager: CryptoManager) { + let calculated = this.calculateHmac(cryptoManager, this.encryptedContent()); + let hmac = this._encrypted.slice(0, HMAC_SIZE_BYTES); + + super.verifyBase(hmac, calculated); + } + + private encryptedContent(): byte[] { + return this._encrypted.slice(HMAC_SIZE_BYTES); + } +} + +enum SyncEntryType { + Add = 'ADD', + Delete = 'DEL', + Change = 'CHANGE', +} + +export class SyncEntry { + type: SyncEntryType; + content: string; + + constructor(json?: any) { + CastJson(json, this); + } +} + +export class Entry extends BaseJournal { + setSyncEntry(cryptoManager: CryptoManager, info: SyncEntry, prevUid: string | null) { + this._content = JSON.stringify(info); + this._encrypted = cryptoManager.encrypt(this._content); + this.uid = hmacToHex(this.calculateHmac(cryptoManager, this._encrypted, prevUid)); + } + + getSyncEntry(cryptoManager: CryptoManager, prevUid: string | null): SyncEntry { + if (this._content === undefined) { + this._content = JSON.parse(cryptoManager.decrypt(this._encrypted)); + } + + this.verify(cryptoManager, prevUid); + + return new SyncEntry(this._content); + } + + verify(cryptoManager: CryptoManager, prevUid: string | null) { + let calculated = this.calculateHmac(cryptoManager, this._encrypted, prevUid); + let hmac = sjcl.codec.bytes.fromBits(sjcl.codec.hex.toBits(this.uid)); + + super.verifyBase(hmac, calculated); + } + + private calculateHmac(cryptoManager: CryptoManager, encrypted: byte[], prevUid: string | null): byte[] { + let prefix = (prevUid !== null) ? stringToByteArray(prevUid) : []; + return cryptoManager.hmac(prefix.concat(encrypted)); + } +} + +// FIXME: baseUrl and apiBase should be the right type all around. + +class BaseNetwork { + apiBase: any; // FIXME + + static urlExtend(_baseUrl: URL, segments: Array): URL { + let baseUrl = _baseUrl as any; + baseUrl = baseUrl.clone(); + for (const segment of segments) { + baseUrl.segment(segment); + } + return baseUrl.normalize(); + } + + constructor(apiBase: string) { + this.apiBase = URI(apiBase).normalize(); + } + + // FIXME: Get the correct type for extra + newCall(segments: Array = [], extra: any = {}, _apiBase: URL = this.apiBase): Promise<{} | Array> { + let apiBase = BaseNetwork.urlExtend(_apiBase, segments); + + extra = Object.assign({}, extra); + extra.headers = Object.assign( + { 'Accept': 'application/json' }, + extra.headers); + + return new Promise((resolve, reject) => { + fetch(apiBase.toString(), extra).then((response) => { + response.text().then((text) => { + let json: any; + let body: any = text; + try { + json = JSON.parse(text); + body = json; + } catch (e) { + body = text; + } + + if (response.ok) { + resolve(body); + } else { + reject(new HTTPError(json.detail)); + } + }).catch((error) => { + reject(error); + }); + }).catch((error) => { + reject(error); + }); + }); + } +} + +export class Authenticator extends BaseNetwork { + constructor(apiBase: string) { + super(apiBase); + this.apiBase = BaseNetwork.urlExtend(this.apiBase, ['api-token-auth', '']); + } + + getAuthToken(username: string, password: string): Promise { + return new Promise((resolve, reject) => { + // FIXME: should be FormData but doesn't work for whatever reason + let form = 'username=' + encodeURIComponent(username) + + '&password=' + encodeURIComponent(password); + const extra = { + method: 'post', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + }, + body: form + }; + + this.newCall([], extra).then((json: {token: string}) => { + resolve(json.token); + }).catch((error: Error) => { + reject(error); + }); + }); + } +} + +export class BaseManager extends BaseNetwork { + protected credentials: Credentials; + + constructor(credentials: Credentials, apiBase: string, segments: Array) { + super(apiBase); + this.credentials = credentials; + this.apiBase = BaseNetwork.urlExtend(this.apiBase, ['api', 'v1'].concat(segments)); + } + + // FIXME: Get the correct type for extra + newCall(segments: Array = [], extra: any = {}, apiBase: any = this.apiBase): Promise<{} | Array> { + extra = Object.assign({}, extra); + extra.headers = Object.assign( + { + 'Content-Type': 'application/json;charset=UTF-8', + 'Authorization': 'Token ' + this.credentials.authToken + }, + extra.headers); + + return super.newCall(segments, extra, apiBase); + } +} + +export class JournalManager extends BaseManager { + constructor(credentials: Credentials, apiBase: string) { + super(credentials, apiBase, ['journals', '']); + } + + list(): Promise { + return new Promise((resolve, reject) => { + this.newCall().then((json: Array<{}>) => { + resolve(json.map((val: any) => { + let journal = new Journal(); + journal.deserialize(val.uid, val.content); + return journal; + })); + }).catch((error: Error) => { + reject(error); + }); + }); + } + + create(journal: Journal): Promise<{}> { + const extra = { + method: 'post', + body: JSON.stringify(journal.serialize()), + }; + + return this.newCall([], extra); + } + + update(journal: Journal): Promise<{}> { + const extra = { + method: 'put', + body: JSON.stringify(journal.serialize()), + }; + + return this.newCall([journal.uid, ''], extra); + } + + delete(journal: Journal): Promise<{}> { + const extra = { + method: 'delete', + }; + + return this.newCall([journal.uid, ''], extra); + } +} + +export class EntryManager extends BaseManager { + constructor(credentials: Credentials, apiBase: string, journalId: string) { + super(credentials, apiBase, ['journals', journalId, 'entries', '']); + } + + list(lastUid: string | null, limit: number = 0): Promise { + let apiBase = this.apiBase.clone(); + apiBase = apiBase.search({ + last: (lastUid !== null) ? lastUid : undefined, + limit: (limit > 0) ? limit : undefined, + }); + + return new Promise((resolve, reject) => { + this.newCall(undefined, undefined, apiBase).then((json: Array<{}>) => { + resolve(json.map((val: any) => { + let entryl = new Entry(); + entryl.deserialize(val.uid, val.content); + return entryl; + })); + }).catch((error: Error) => { + reject(error); + }); + }); + } + + create(entries: Entry[], lastUid: string | null): Promise<{}> { + let apiBase = this.apiBase.clone(); + apiBase = apiBase.search({ + last: (lastUid !== null) ? lastUid : undefined, + }); + + const extra = { + method: 'post', + body: JSON.stringify(entries.map((x) => x.serialize())), + }; + + return this.newCall(undefined, extra, apiBase); + } +} diff --git a/src/api/Helpers.tsx b/src/api/Helpers.tsx new file mode 100644 index 0000000..ed4de1f --- /dev/null +++ b/src/api/Helpers.tsx @@ -0,0 +1,11 @@ +export type byte = number; +export type base64 = string; + +export function stringToByteArray(str: string): byte[] { + let ret = []; + for (let i = 0 ; i < str.length ; i++) { + ret.push(str.charCodeAt(i)); + } + + return ret; +} diff --git a/src/api/TestConstants.tsx b/src/api/TestConstants.tsx new file mode 100644 index 0000000..bf752ed --- /dev/null +++ b/src/api/TestConstants.tsx @@ -0,0 +1,6 @@ +export const USER = 'test@localhost'; +export const PASSWORD = 'SomePassword'; +export const keyBase64 = + 'Gpn6j6WJ/9JJbVkWhmEfZjlqSps5rwEOzjUOO0rqufvb4vtT4UfRgx0uMivuGwjF7/8Y1z1glIASX7Oz/4l2jucgf+lAzg2oTZFodWkXRZCDmFa7c9' + + 'a8/04xIs7koFmUH34Rl9XXW6V2/GDVigQhQU8uWnrGo795tupoNQMbtB8RgMX5GyuxR55FvcybHpYBbwrDIsKvXcBxWFEscdNU8zyeq3yjvDo/W/y2' + + '4dApW3mnNo7vswoL2rpkZj3dqw=='; diff --git a/yarn.lock b/yarn.lock index 7e9fc6a..8180ed2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,10 +2,22 @@ # yarn lockfile v1 +"@types/history@*": + version "4.6.2" + resolved "https://registry.yarnpkg.com/@types/history/-/history-4.6.2.tgz#12cfaba693ba20f114ed5765467ff25fdf67ddb0" + +"@types/isomorphic-fetch@^0.0.34": + version "0.0.34" + resolved "https://registry.yarnpkg.com/@types/isomorphic-fetch/-/isomorphic-fetch-0.0.34.tgz#3c3483e606c041378438e951464f00e4e60706d6" + "@types/jest@^21.1.8": version "21.1.8" resolved "https://registry.yarnpkg.com/@types/jest/-/jest-21.1.8.tgz#d497213725684f1e5a37900b17a47c9c018f1a97" +"@types/jquery@*": + version "3.2.16" + resolved "https://registry.yarnpkg.com/@types/jquery/-/jquery-3.2.16.tgz#04419c404a3194350e7d3f339a90e72c88db3111" + "@types/node@*", "@types/node@^8.0.53": version "8.0.53" resolved "https://registry.yarnpkg.com/@types/node/-/node-8.0.53.tgz#396b35af826fa66aad472c8cb7b8d5e277f4e6d8" @@ -17,10 +29,35 @@ "@types/node" "*" "@types/react" "*" +"@types/react-router-dom@^4.2.3": + version "4.2.3" + resolved "https://registry.yarnpkg.com/@types/react-router-dom/-/react-router-dom-4.2.3.tgz#06e0b67ff536adc0681dffdbe592ae91fb85887d" + dependencies: + "@types/history" "*" + "@types/react" "*" + "@types/react-router" "*" + +"@types/react-router@*", "@types/react-router@^4.0.19": + version "4.0.19" + resolved "https://registry.yarnpkg.com/@types/react-router/-/react-router-4.0.19.tgz#4258eb59a9c3a01b5adf1bf9b14f068a7699bbb6" + dependencies: + "@types/history" "*" + "@types/react" "*" + "@types/react@*", "@types/react@^16.0.25": version "16.0.25" resolved "https://registry.yarnpkg.com/@types/react/-/react-16.0.25.tgz#bf696b83fe480c5e0eff4335ee39ebc95884a1ed" +"@types/sjcl@^1.0.28": + version "1.0.28" + resolved "https://registry.yarnpkg.com/@types/sjcl/-/sjcl-1.0.28.tgz#4693eb6943e385e844a70fb25b4699db286c7214" + +"@types/urijs@^1.15.34": + version "1.15.34" + resolved "https://registry.yarnpkg.com/@types/urijs/-/urijs-1.15.34.tgz#b9d5954e9beaabb6fc48c2127d8df15ac6393256" + dependencies: + "@types/jquery" "*" + abab@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/abab/-/abab-1.0.4.tgz#5faad9c2c07f60dd76770f71cf025b62a63cfd4e" @@ -2185,6 +2222,16 @@ he@1.1.x: version "1.1.1" resolved "https://registry.yarnpkg.com/he/-/he-1.1.1.tgz#93410fd21b009735151f8868c2f271f3427e23fd" +history@^4.7.2: + version "4.7.2" + resolved "https://registry.yarnpkg.com/history/-/history-4.7.2.tgz#22b5c7f31633c5b8021c7f4a8a954ac139ee8d5b" + dependencies: + invariant "^2.2.1" + loose-envify "^1.2.0" + resolve-pathname "^2.2.0" + value-equal "^0.4.0" + warning "^3.0.0" + hmac-drbg@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" @@ -2201,6 +2248,10 @@ hoek@4.x.x: version "4.2.0" resolved "https://registry.yarnpkg.com/hoek/-/hoek-4.2.0.tgz#72d9d0754f7fe25ca2d01ad8f8f9a9449a89526d" +hoist-non-react-statics@^2.3.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.3.1.tgz#343db84c6018c650778898240135a1420ee22ce0" + home-or-tmp@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/home-or-tmp/-/home-or-tmp-2.0.0.tgz#e36c3f2d2cae7d746a857e38d18d5f32a7882db8" @@ -2411,7 +2462,7 @@ interpret@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.1.0.tgz#7ed1b1410c6a0e0f78cf95d3b8440c63f78b8614" -invariant@^2.2.2: +invariant@^2.2.1, invariant@^2.2.2: version "2.2.2" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.2.tgz#9e1f56ac0acdb6bf303306f338be3b204ae60360" dependencies: @@ -3166,7 +3217,7 @@ longest@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097" -loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.3.1: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.3.1.tgz#d1a8ad33fa9ce0e713d65fdd0ac8b748d478c848" dependencies: @@ -3758,7 +3809,7 @@ path-to-regexp@0.1.7: version "0.1.7" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" -path-to-regexp@^1.0.1: +path-to-regexp@^1.0.1, path-to-regexp@^1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.7.0.tgz#59fde0f435badacba103a84e9d3bc64e96b9937d" dependencies: @@ -4166,7 +4217,7 @@ promise@^7.1.1: dependencies: asap "~2.0.3" -prop-types@^15.6.0: +prop-types@^15.5.4, prop-types@^15.6.0: version "15.6.0" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.0.tgz#ceaf083022fc46b4a35f69e13ef75aed0d639856" dependencies: @@ -4320,6 +4371,29 @@ react-error-overlay@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-3.0.0.tgz#c2bc8f4d91f1375b3dad6d75265d51cd5eeaf655" +react-router-dom@^4.2.2: + version "4.2.2" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-4.2.2.tgz#c8a81df3adc58bba8a76782e946cbd4eae649b8d" + dependencies: + history "^4.7.2" + invariant "^2.2.2" + loose-envify "^1.3.1" + prop-types "^15.5.4" + react-router "^4.2.0" + warning "^3.0.0" + +react-router@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-4.2.0.tgz#61f7b3e3770daeb24062dae3eedef1b054155986" + dependencies: + history "^4.7.2" + hoist-non-react-statics "^2.3.0" + invariant "^2.2.2" + loose-envify "^1.3.1" + path-to-regexp "^1.7.0" + prop-types "^15.5.4" + warning "^3.0.0" + react-scripts-ts@2.8.0: version "2.8.0" resolved "https://registry.yarnpkg.com/react-scripts-ts/-/react-scripts-ts-2.8.0.tgz#6ef17a490725fd34ca3ba8829354581a97b310e8" @@ -4613,6 +4687,10 @@ resolve-dir@^1.0.0: expand-tilde "^2.0.0" global-modules "^1.0.0" +resolve-pathname@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/resolve-pathname/-/resolve-pathname-2.2.0.tgz#7e9ae21ed815fd63ab189adeee64dc831eefa879" + resolve@1.1.7: version "1.1.7" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.1.7.tgz#203114d82ad2c5ed9e8e0411b3932875e889e97b" @@ -4808,6 +4886,10 @@ signal-exit@^3.0.0, signal-exit@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" +sjcl@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/sjcl/-/sjcl-1.0.7.tgz#32b365a50dc9bba26b88ba3c9df8ea34217d9f45" + slash@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55" @@ -5468,6 +5550,10 @@ validate-npm-package-license@^3.0.1: spdx-correct "~1.0.0" spdx-expression-parse "~1.0.0" +value-equal@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-0.4.0.tgz#c5bdd2f54ee093c04839d71ce2e4758a6890abc7" + vary@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" @@ -5496,6 +5582,12 @@ walker@~1.0.5: dependencies: makeerror "1.0.x" +warning@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/warning/-/warning-3.0.0.tgz#32e5377cb572de4ab04753bdf8821c01ed605b7c" + dependencies: + loose-envify "^1.0.0" + watch@~0.10.0: version "0.10.0" resolved "https://registry.yarnpkg.com/watch/-/watch-0.10.0.tgz#77798b2da0f9910d595f1ace5b0c2258521f21dc"