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 (
-
-
-
-
Welcome to React
+
+
+
+
+
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"