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.
master
Tom Hacohen 7 years ago
parent 2d6628038d
commit e0cc13cfd2

@ -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"
}
}

@ -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 (
<div className="App">
<div className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<h2>Welcome to React</h2>
<Router history={customHistory}>
<div className="App">
<div className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<h2>Welcome to React</h2>
</div>
<p className="App-intro">
To get started, edit <code>src/App.tsx</code> and save to reload.
</p>
</div>
<p className="App-intro">
To get started, edit <code>src/App.tsx</code> and save to reload.
</p>
</div>
</Router>
);
}
}

@ -0,0 +1 @@
export const CURRENT_VERSION = 2;

@ -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')));
});

@ -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);
}
}

@ -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();
});

@ -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<T> {
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<CollectionInfo> {
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<SyncEntry> {
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<string>): 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<string> = [], extra: any = {}, _apiBase: URL = this.apiBase): Promise<{} | Array<any>> {
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<string> {
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<string>) {
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<string> = [], extra: any = {}, apiBase: any = this.apiBase): Promise<{} | Array<any>> {
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<Journal[]> {
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<Entry[]> {
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);
}
}

@ -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;
}

@ -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==';

@ -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"

Loading…
Cancel
Save