Move the etesync-js API to its own repo.
parent
f3de2a641a
commit
c7d4a15f5f
@ -1 +0,0 @@
|
|||||||
export const CURRENT_VERSION = 2;
|
|
@ -1,41 +0,0 @@
|
|||||||
import { CryptoManager, AsymmetricCryptoManager, deriveKey } from './Crypto';
|
|
||||||
import { USER, PASSWORD, keyBase64 } from './TestConstants';
|
|
||||||
|
|
||||||
import { stringToByteArray } from './Helpers';
|
|
||||||
|
|
||||||
import sjcl from 'sjcl';
|
|
||||||
sjcl.random.addEntropy('seedForTheTests', 1024, 'FakeSeed');
|
|
||||||
|
|
||||||
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')));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Asymmetric encryption', () => {
|
|
||||||
const keyPair = AsymmetricCryptoManager.generateKeyPair();
|
|
||||||
const cryptoManager = new AsymmetricCryptoManager(keyPair);
|
|
||||||
|
|
||||||
const clearText = [1, 2, 4, 5];
|
|
||||||
const cipher = cryptoManager.encryptBytes(keyPair.publicKey, clearText);
|
|
||||||
expect(clearText).toEqual(cryptoManager.decryptBytes(cipher));
|
|
||||||
});
|
|
@ -1,165 +0,0 @@
|
|||||||
import sjcl from 'sjcl';
|
|
||||||
import NodeRSA from 'node-rsa';
|
|
||||||
|
|
||||||
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 class AsymmetricKeyPair {
|
|
||||||
public publicKey: byte[];
|
|
||||||
public privateKey: byte[];
|
|
||||||
|
|
||||||
constructor(publicKey: byte[], privateKey: byte[]) {
|
|
||||||
this.publicKey = publicKey;
|
|
||||||
this.privateKey = privateKey;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function genUid() {
|
|
||||||
const rand = sjcl.random.randomWords(4);
|
|
||||||
return sjcl.codec.hex.fromBits(hmac256(rand, rand));
|
|
||||||
}
|
|
||||||
|
|
||||||
function hmac256(salt: sjcl.BitArray, key: sjcl.BitArray) {
|
|
||||||
const hmac = new sjcl.misc.hmac(salt);
|
|
||||||
return hmac.encrypt(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CryptoManager {
|
|
||||||
|
|
||||||
public static fromDerivedKey(key: byte[], version: number = Constants.CURRENT_VERSION) {
|
|
||||||
// FIXME: Cleanup this hack
|
|
||||||
const ret = new CryptoManager('', '', version);
|
|
||||||
ret.key = sjcl.codec.bytes.toBits(key);
|
|
||||||
ret._updateDerivedKeys();
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
public version: number;
|
|
||||||
public key: sjcl.BitArray;
|
|
||||||
public cipherKey: sjcl.BitArray;
|
|
||||||
public hmacKey: sjcl.BitArray;
|
|
||||||
|
|
||||||
public cipherWords = 4;
|
|
||||||
|
|
||||||
constructor(_keyBase64: base64, salt: string, version: number = Constants.CURRENT_VERSION) {
|
|
||||||
this.version = version;
|
|
||||||
const 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
public _updateDerivedKeys() {
|
|
||||||
this.cipherKey = hmac256(sjcl.codec.utf8String.toBits('aes'), this.key);
|
|
||||||
this.hmacKey = hmac256(sjcl.codec.utf8String.toBits('hmac'), this.key);
|
|
||||||
}
|
|
||||||
|
|
||||||
public encryptBits(content: sjcl.BitArray): byte[] {
|
|
||||||
const iv = sjcl.random.randomWords(this.cipherWords);
|
|
||||||
|
|
||||||
const prp = new sjcl.cipher.aes(this.cipherKey);
|
|
||||||
const cipherText = sjcl.mode.cbc.encrypt(prp, content, iv);
|
|
||||||
return sjcl.codec.bytes.fromBits(iv.concat(cipherText));
|
|
||||||
}
|
|
||||||
|
|
||||||
public decryptBits(content: byte[]): sjcl.BitArray {
|
|
||||||
const cipherText = sjcl.codec.bytes.toBits(content);
|
|
||||||
const iv = cipherText.splice(0, this.cipherWords);
|
|
||||||
|
|
||||||
const prp = new sjcl.cipher.aes(this.cipherKey);
|
|
||||||
const clearText = sjcl.mode.cbc.decrypt(prp, cipherText, iv);
|
|
||||||
return clearText;
|
|
||||||
}
|
|
||||||
|
|
||||||
public encryptBytes(content: byte[]): byte[] {
|
|
||||||
return this.encryptBits(sjcl.codec.bytes.toBits(content));
|
|
||||||
}
|
|
||||||
|
|
||||||
public decryptBytes(content: byte[]): byte[] {
|
|
||||||
return sjcl.codec.bytes.fromBits(this.decryptBits(content));
|
|
||||||
}
|
|
||||||
|
|
||||||
public encrypt(content: string): byte[] {
|
|
||||||
return this.encryptBits(sjcl.codec.utf8String.toBits(content));
|
|
||||||
}
|
|
||||||
|
|
||||||
public decrypt(content: byte[]): string {
|
|
||||||
return sjcl.codec.utf8String.fromBits(this.decryptBits(content));
|
|
||||||
}
|
|
||||||
|
|
||||||
public getEncryptedKey(keyPair: AsymmetricKeyPair, publicKey: byte[]) {
|
|
||||||
const cryptoManager = new AsymmetricCryptoManager(keyPair);
|
|
||||||
return cryptoManager.encryptBytes(publicKey, sjcl.codec.bytes.fromBits(this.key));
|
|
||||||
}
|
|
||||||
|
|
||||||
public hmac(content: byte[]): byte[] {
|
|
||||||
return sjcl.codec.bytes.fromBits(this.hmacBase(content));
|
|
||||||
}
|
|
||||||
|
|
||||||
public hmac64(content: byte[]): base64 {
|
|
||||||
return sjcl.codec.base64.fromBits(this.hmacBase(content));
|
|
||||||
}
|
|
||||||
|
|
||||||
public 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function bufferToArray(buffer: Buffer) {
|
|
||||||
return Array.prototype.slice.call(buffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AsymmetricCryptoManager {
|
|
||||||
|
|
||||||
public static generateKeyPair() {
|
|
||||||
const keyPair = new NodeRSA();
|
|
||||||
keyPair.generateKeyPair(3072, 65537);
|
|
||||||
const pubkey = keyPair.exportKey('pkcs8-public-der') as Buffer;
|
|
||||||
const privkey = keyPair.exportKey('pkcs8-private-der') as Buffer;
|
|
||||||
return new AsymmetricKeyPair(
|
|
||||||
bufferToArray(pubkey), bufferToArray(privkey));
|
|
||||||
}
|
|
||||||
public keyPair: NodeRSA;
|
|
||||||
|
|
||||||
constructor(keyPair: AsymmetricKeyPair) {
|
|
||||||
this.keyPair = new NodeRSA();
|
|
||||||
this.keyPair.importKey(Buffer.from(keyPair.privateKey), 'pkcs8-der');
|
|
||||||
}
|
|
||||||
|
|
||||||
public encryptBytes(publicKey: byte[], content: byte[]): byte[] {
|
|
||||||
const key = new NodeRSA();
|
|
||||||
key.importKey(Buffer.from(publicKey), 'pkcs8-public-der');
|
|
||||||
return bufferToArray(key.encrypt(Buffer.from(content), 'buffer'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public decryptBytes(content: byte[]): byte[] {
|
|
||||||
return bufferToArray(this.keyPair.decrypt(Buffer.from(content), 'buffer'));
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,168 +0,0 @@
|
|||||||
import * as EteSync from './EteSync';
|
|
||||||
|
|
||||||
import sjcl from 'sjcl';
|
|
||||||
|
|
||||||
import { USER, PASSWORD, keyBase64 } from './TestConstants';
|
|
||||||
|
|
||||||
const testApiBase = 'http://localhost:8000';
|
|
||||||
|
|
||||||
sjcl.random.addEntropy('seedForTheTests', 1024, 'FakeSeed');
|
|
||||||
|
|
||||||
|
|
||||||
let credentials: EteSync.Credentials;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const 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 () => {
|
|
||||||
const 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' });
|
|
||||||
const 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
|
|
||||||
const 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 () => {
|
|
||||||
const 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' });
|
|
||||||
const journal = new EteSync.Journal();
|
|
||||||
journal.setInfo(cryptoManager, info1);
|
|
||||||
|
|
||||||
await expect(journalManager.create(journal)).resolves.toBeDefined();
|
|
||||||
|
|
||||||
const entryManager = new EteSync.EntryManager(credentials, testApiBase, journal.uid);
|
|
||||||
|
|
||||||
{
|
|
||||||
const entries = await entryManager.list(null);
|
|
||||||
expect(entries.length).toBe(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const syncEntry = new EteSync.SyncEntry({ action: 'ADD', content: 'bla' });
|
|
||||||
let prevUid = null;
|
|
||||||
const entry = new EteSync.Entry();
|
|
||||||
entry.setSyncEntry(cryptoManager, syncEntry, prevUid);
|
|
||||||
|
|
||||||
await expect(entryManager.create([entry], prevUid)).resolves.toBeDefined();
|
|
||||||
prevUid = entry.uid;
|
|
||||||
|
|
||||||
{
|
|
||||||
// Verify we get back what we sent
|
|
||||||
const entries = await entryManager.list(null);
|
|
||||||
expect(entries[0].serialize()).toEqual(entry.serialize());
|
|
||||||
syncEntry.uid = entries[0].uid;
|
|
||||||
expect(entries[0].getSyncEntry(cryptoManager, null)).toEqual(syncEntry);
|
|
||||||
}
|
|
||||||
|
|
||||||
let entry2 = new EteSync.Entry();
|
|
||||||
entry2.setSyncEntry(cryptoManager, syncEntry, prevUid);
|
|
||||||
|
|
||||||
{
|
|
||||||
// Try to push good entries with a bad prevUid
|
|
||||||
const 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:
|
|
||||||
let 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');
|
|
||||||
await expect(entryManager.create([entry2], prevUid)).resolves.toBeDefined();
|
|
||||||
|
|
||||||
const entries = await entryManager.list(null);
|
|
||||||
|
|
||||||
expect(() => {
|
|
||||||
let prev = null;
|
|
||||||
for (const ent of entries) {
|
|
||||||
expect(ent.getSyncEntry(cryptoManager, prev)).toBeDefined();
|
|
||||||
prev = ent.uid;
|
|
||||||
}
|
|
||||||
}).toThrowError();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('User info sync', async () => {
|
|
||||||
const cryptoManager = new EteSync.CryptoManager(keyBase64, 'userInfo');
|
|
||||||
const userInfoManager = new EteSync.UserInfoManager(credentials, testApiBase);
|
|
||||||
|
|
||||||
// Get when there's nothing
|
|
||||||
await expect(userInfoManager.fetch(USER)).rejects.toBeInstanceOf(EteSync.HTTPError);
|
|
||||||
|
|
||||||
// Create
|
|
||||||
const userInfo = new EteSync.UserInfo(USER);
|
|
||||||
userInfo.setKeyPair(cryptoManager, new EteSync.AsymmetricKeyPair([0, 1, 2, 3], [4, 5, 6, 6]));
|
|
||||||
await expect(userInfoManager.create(userInfo)).resolves.not.toBeNull();
|
|
||||||
|
|
||||||
// Get
|
|
||||||
let userInfo2 = await userInfoManager.fetch(USER);
|
|
||||||
expect(userInfo2).not.toBeNull();
|
|
||||||
expect(userInfo.getKeyPair(cryptoManager)).toEqual(userInfo2!.getKeyPair(cryptoManager));
|
|
||||||
|
|
||||||
// Update
|
|
||||||
userInfo.setKeyPair(cryptoManager, new EteSync.AsymmetricKeyPair([1, 94, 45], [4, 34, 45, 45]));
|
|
||||||
await userInfoManager.update(userInfo);
|
|
||||||
userInfo2 = await userInfoManager.fetch(USER);
|
|
||||||
expect(userInfo2).not.toBeNull();
|
|
||||||
expect(userInfo.getKeyPair(cryptoManager)).toEqual(userInfo2!.getKeyPair(cryptoManager));
|
|
||||||
|
|
||||||
// Delete
|
|
||||||
await userInfoManager.delete(userInfo);
|
|
||||||
await expect(userInfoManager.fetch(USER)).rejects.toBeInstanceOf(EteSync.HTTPError);
|
|
||||||
});
|
|
@ -1,623 +0,0 @@
|
|||||||
import sjcl from 'sjcl';
|
|
||||||
import URI from 'urijs';
|
|
||||||
|
|
||||||
import * as Constants from './Constants';
|
|
||||||
|
|
||||||
import { byte, base64, stringToByteArray } from './Helpers';
|
|
||||||
import { CryptoManager, AsymmetricCryptoManager, AsymmetricKeyPair, HMAC_SIZE_BYTES } from './Crypto';
|
|
||||||
export { CryptoManager, AsymmetricCryptoManager, AsymmetricKeyPair, deriveKey, genUid } from './Crypto';
|
|
||||||
|
|
||||||
type URI = typeof URI;
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class EncryptionPasswordError extends ExtendableError {
|
|
||||||
constructor(message: any) {
|
|
||||||
super(message);
|
|
||||||
Object.setPrototypeOf(this, EncryptionPasswordError.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 {
|
|
||||||
public email: string;
|
|
||||||
public authToken: string;
|
|
||||||
|
|
||||||
constructor(email: string, authToken: string) {
|
|
||||||
this.email = email;
|
|
||||||
this.authToken = authToken;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CollectionInfo {
|
|
||||||
public uid: string;
|
|
||||||
public type: string;
|
|
||||||
public displayName: string;
|
|
||||||
public description: string;
|
|
||||||
public color: number;
|
|
||||||
|
|
||||||
constructor(json?: any) {
|
|
||||||
CastJson(json, this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BaseItemJson {
|
|
||||||
content: base64;
|
|
||||||
}
|
|
||||||
|
|
||||||
class BaseItem<T extends BaseItemJson> {
|
|
||||||
protected _json: T;
|
|
||||||
protected _encrypted: byte[];
|
|
||||||
protected _content?: object;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this._json = {} as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
public deserialize(json: T) {
|
|
||||||
this._json = Object.assign({}, json);
|
|
||||||
if (json.content) {
|
|
||||||
this._encrypted = sjcl.codec.bytes.fromBits(sjcl.codec.base64.toBits(json.content));
|
|
||||||
}
|
|
||||||
this._content = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
public serialize(): T {
|
|
||||||
return Object.assign(
|
|
||||||
{},
|
|
||||||
this._json,
|
|
||||||
{ 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]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BaseJson extends BaseItemJson {
|
|
||||||
uid: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
class BaseJournal<T extends BaseJson> extends BaseItem<T> {
|
|
||||||
get uid(): string {
|
|
||||||
return this._json.uid;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JournalJson extends BaseJson {
|
|
||||||
version: number;
|
|
||||||
owner: string;
|
|
||||||
readOnly?: boolean;
|
|
||||||
key?: base64;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Journal extends BaseJournal<JournalJson> {
|
|
||||||
constructor(initial?: Partial<JournalJson>, version: number = Constants.CURRENT_VERSION) {
|
|
||||||
super();
|
|
||||||
this._json = {
|
|
||||||
...this._json,
|
|
||||||
version,
|
|
||||||
...initial,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
get key(): byte[] | undefined {
|
|
||||||
if (this._json.key) {
|
|
||||||
return sjcl.codec.bytes.fromBits(sjcl.codec.base64.toBits(this._json.key));
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
get owner(): string | undefined {
|
|
||||||
return this._json.owner;
|
|
||||||
}
|
|
||||||
|
|
||||||
get readOnly(): boolean | undefined {
|
|
||||||
return this._json.readOnly;
|
|
||||||
}
|
|
||||||
|
|
||||||
get version(): number {
|
|
||||||
return this._json.version;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getCryptoManager(derived: string, keyPair: AsymmetricKeyPair) {
|
|
||||||
if (this.key) {
|
|
||||||
const asymmetricCryptoManager = new AsymmetricCryptoManager(keyPair);
|
|
||||||
const derivedJournalKey = asymmetricCryptoManager.decryptBytes(this.key);
|
|
||||||
return CryptoManager.fromDerivedKey(derivedJournalKey, this.version);
|
|
||||||
} else {
|
|
||||||
return new CryptoManager(derived, this.uid, this.version);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public setInfo(cryptoManager: CryptoManager, info: CollectionInfo) {
|
|
||||||
this._json.uid = info.uid;
|
|
||||||
this._content = info;
|
|
||||||
const encrypted = cryptoManager.encrypt(JSON.stringify(this._content));
|
|
||||||
this._encrypted = this.calculateHmac(cryptoManager, encrypted).concat(encrypted);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getInfo(cryptoManager: CryptoManager): CollectionInfo {
|
|
||||||
this.verify(cryptoManager);
|
|
||||||
|
|
||||||
if (this._content === undefined) {
|
|
||||||
this._content = JSON.parse(cryptoManager.decrypt(this.encryptedContent()));
|
|
||||||
}
|
|
||||||
|
|
||||||
const ret = new CollectionInfo(this._content);
|
|
||||||
ret.uid = this.uid;
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
|
|
||||||
public calculateHmac(cryptoManager: CryptoManager, encrypted: byte[]): byte[] {
|
|
||||||
const prefix = stringToByteArray(this.uid);
|
|
||||||
return cryptoManager.hmac(prefix.concat(encrypted));
|
|
||||||
}
|
|
||||||
|
|
||||||
public verify(cryptoManager: CryptoManager) {
|
|
||||||
const calculated = this.calculateHmac(cryptoManager, this.encryptedContent());
|
|
||||||
const hmac = this._encrypted.slice(0, HMAC_SIZE_BYTES);
|
|
||||||
|
|
||||||
super.verifyBase(hmac, calculated);
|
|
||||||
}
|
|
||||||
|
|
||||||
private encryptedContent(): byte[] {
|
|
||||||
return this._encrypted.slice(HMAC_SIZE_BYTES);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum SyncEntryAction {
|
|
||||||
Add = 'ADD',
|
|
||||||
Delete = 'DELETE',
|
|
||||||
Change = 'CHANGE',
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SyncEntry {
|
|
||||||
public uid?: string;
|
|
||||||
public action: SyncEntryAction;
|
|
||||||
public content: string;
|
|
||||||
|
|
||||||
constructor(json?: any, uid?: string) {
|
|
||||||
CastJson(json, this);
|
|
||||||
this.uid = uid;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EntryJson = BaseJson;
|
|
||||||
|
|
||||||
export class Entry extends BaseJournal<EntryJson> {
|
|
||||||
public setSyncEntry(cryptoManager: CryptoManager, info: SyncEntry, prevUid: string | null) {
|
|
||||||
this._content = info;
|
|
||||||
this._encrypted = cryptoManager.encrypt(JSON.stringify(this._content));
|
|
||||||
this._json.uid = hmacToHex(this.calculateHmac(cryptoManager, this._encrypted, prevUid));
|
|
||||||
}
|
|
||||||
|
|
||||||
public getSyncEntry(cryptoManager: CryptoManager, prevUid: string | null): SyncEntry {
|
|
||||||
this.verify(cryptoManager, prevUid);
|
|
||||||
|
|
||||||
if (this._content === undefined) {
|
|
||||||
this._content = JSON.parse(cryptoManager.decrypt(this._encrypted));
|
|
||||||
}
|
|
||||||
|
|
||||||
return new SyncEntry(this._content, this.uid);
|
|
||||||
}
|
|
||||||
|
|
||||||
public verify(cryptoManager: CryptoManager, prevUid: string | null) {
|
|
||||||
const calculated = this.calculateHmac(cryptoManager, this._encrypted, prevUid);
|
|
||||||
const 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[] {
|
|
||||||
const prefix = (prevUid !== null) ? stringToByteArray(prevUid) : [];
|
|
||||||
return cryptoManager.hmac(prefix.concat(encrypted));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserInfoJson extends BaseItemJson {
|
|
||||||
version?: number;
|
|
||||||
owner?: string;
|
|
||||||
pubkey: base64;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UserInfo extends BaseItem<UserInfoJson> {
|
|
||||||
public _owner: string;
|
|
||||||
|
|
||||||
constructor(owner: string, version: number = Constants.CURRENT_VERSION) {
|
|
||||||
super();
|
|
||||||
this._json.version = version;
|
|
||||||
this._owner = owner;
|
|
||||||
}
|
|
||||||
|
|
||||||
get version(): number {
|
|
||||||
return this._json.version!;
|
|
||||||
}
|
|
||||||
|
|
||||||
get owner(): string {
|
|
||||||
return this._owner;
|
|
||||||
}
|
|
||||||
|
|
||||||
get publicKey() {
|
|
||||||
return this._json.pubkey;
|
|
||||||
}
|
|
||||||
|
|
||||||
public serialize(): UserInfoJson {
|
|
||||||
const ret = super.serialize();
|
|
||||||
ret.owner = this._owner;
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getCryptoManager(derived: string) {
|
|
||||||
return new CryptoManager(derived, 'userInfo', this.version);
|
|
||||||
}
|
|
||||||
|
|
||||||
public setKeyPair(cryptoManager: CryptoManager, keyPair: AsymmetricKeyPair) {
|
|
||||||
this._json.pubkey = sjcl.codec.base64.fromBits(sjcl.codec.bytes.toBits(keyPair.publicKey));
|
|
||||||
this._content = keyPair.privateKey;
|
|
||||||
const encrypted = cryptoManager.encryptBytes(keyPair.privateKey);
|
|
||||||
this._encrypted = this.calculateHmac(cryptoManager, encrypted).concat(encrypted);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getKeyPair(cryptoManager: CryptoManager): AsymmetricKeyPair {
|
|
||||||
this.verify(cryptoManager);
|
|
||||||
|
|
||||||
if (this._content === undefined) {
|
|
||||||
this._content = cryptoManager.decryptBytes(this.encryptedContent());
|
|
||||||
}
|
|
||||||
|
|
||||||
const pubkey = sjcl.codec.bytes.fromBits(sjcl.codec.base64.toBits(this._json.pubkey));
|
|
||||||
return new AsymmetricKeyPair(pubkey, this._content as byte[]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public calculateHmac(cryptoManager: CryptoManager, encrypted: byte[]): byte[] {
|
|
||||||
const postfix = sjcl.codec.bytes.fromBits(sjcl.codec.base64.toBits(this._json.pubkey));
|
|
||||||
return cryptoManager.hmac(encrypted.concat(postfix));
|
|
||||||
}
|
|
||||||
|
|
||||||
public verify(cryptoManager: CryptoManager) {
|
|
||||||
const calculated = this.calculateHmac(cryptoManager, this.encryptedContent());
|
|
||||||
const hmac = this._encrypted.slice(0, HMAC_SIZE_BYTES);
|
|
||||||
|
|
||||||
super.verifyBase(hmac, calculated);
|
|
||||||
}
|
|
||||||
|
|
||||||
private encryptedContent(): byte[] {
|
|
||||||
return this._encrypted.slice(HMAC_SIZE_BYTES);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// FIXME: baseUrl and apiBase should be the right type all around.
|
|
||||||
|
|
||||||
class BaseNetwork {
|
|
||||||
|
|
||||||
public static urlExtend(_baseUrl: URI, segments: string[]): URI {
|
|
||||||
let baseUrl = _baseUrl as any;
|
|
||||||
baseUrl = baseUrl.clone();
|
|
||||||
for (const segment of segments) {
|
|
||||||
baseUrl.segment(segment);
|
|
||||||
}
|
|
||||||
return baseUrl.normalize();
|
|
||||||
}
|
|
||||||
public apiBase: any; // FIXME
|
|
||||||
|
|
||||||
constructor(apiBase: string) {
|
|
||||||
this.apiBase = URI(apiBase).normalize();
|
|
||||||
}
|
|
||||||
|
|
||||||
public newCall<T = any>(segments: string[] = [], extra: RequestInit = {}, _apiBase: URI = this.apiBase): Promise<T> {
|
|
||||||
const apiBase = BaseNetwork.urlExtend(_apiBase, segments);
|
|
||||||
|
|
||||||
extra = {
|
|
||||||
...extra,
|
|
||||||
headers: {
|
|
||||||
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 {
|
|
||||||
if (json) {
|
|
||||||
reject(new HTTPError(json.detail || json.non_field_errors || JSON.stringify(json)));
|
|
||||||
} else {
|
|
||||||
reject(new HTTPError(body));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}).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', '']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public getAuthToken(username: string, password: string): Promise<string> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
// FIXME: should be FormData but doesn't work for whatever reason
|
|
||||||
const 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<{token: string}>([], extra).then((json) => {
|
|
||||||
resolve(json.token);
|
|
||||||
}).catch((error: Error) => {
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BaseManager extends BaseNetwork {
|
|
||||||
protected credentials: Credentials;
|
|
||||||
|
|
||||||
constructor(credentials: Credentials, apiBase: string, segments: string[]) {
|
|
||||||
super(apiBase);
|
|
||||||
this.credentials = credentials;
|
|
||||||
this.apiBase = BaseNetwork.urlExtend(this.apiBase, ['api', 'v1'].concat(segments));
|
|
||||||
}
|
|
||||||
|
|
||||||
public newCall<T = any>(segments: string[] = [], extra: RequestInit = {}, apiBase: any = this.apiBase): Promise<T> {
|
|
||||||
extra = {
|
|
||||||
...extra,
|
|
||||||
headers: {
|
|
||||||
'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', '']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public fetch(journalUid: string): Promise<Journal> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this.newCall<JournalJson>([journalUid, '']).then((json) => {
|
|
||||||
const journal = new Journal({ uid: json.uid }, json.version);
|
|
||||||
journal.deserialize(json);
|
|
||||||
resolve(journal);
|
|
||||||
}).catch((error: Error) => {
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public list(): Promise<Journal[]> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this.newCall<JournalJson[]>().then((json) => {
|
|
||||||
resolve(json.map((val: JournalJson) => {
|
|
||||||
const journal = new Journal({ uid: val.uid }, val.version);
|
|
||||||
journal.deserialize(val);
|
|
||||||
return journal;
|
|
||||||
}));
|
|
||||||
}).catch((error: Error) => {
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public create(journal: Journal): Promise<{}> {
|
|
||||||
const extra = {
|
|
||||||
method: 'post',
|
|
||||||
body: JSON.stringify(journal.serialize()),
|
|
||||||
};
|
|
||||||
|
|
||||||
return this.newCall<Journal>([], extra);
|
|
||||||
}
|
|
||||||
|
|
||||||
public update(journal: Journal): Promise<{}> {
|
|
||||||
const extra = {
|
|
||||||
method: 'put',
|
|
||||||
body: JSON.stringify(journal.serialize()),
|
|
||||||
};
|
|
||||||
|
|
||||||
return this.newCall<Journal>([journal.uid, ''], extra);
|
|
||||||
}
|
|
||||||
|
|
||||||
public 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', '']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public list(lastUid: string | null, limit = 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<EntryJson[]>(undefined, undefined, apiBase).then((json) => {
|
|
||||||
resolve(json.map((val) => {
|
|
||||||
const entry = new Entry();
|
|
||||||
entry.deserialize(val);
|
|
||||||
return entry;
|
|
||||||
}));
|
|
||||||
}).catch((error: Error) => {
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JournalMemberJson {
|
|
||||||
user: string;
|
|
||||||
key: base64;
|
|
||||||
readOnly?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class JournalMembersManager extends BaseManager {
|
|
||||||
constructor(credentials: Credentials, apiBase: string, journalId: string) {
|
|
||||||
super(credentials, apiBase, ['journals', journalId, 'members', '']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public list(): Promise<JournalMemberJson[]> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this.newCall<JournalMemberJson[]>().then((json) => {
|
|
||||||
resolve(json.map((val) => {
|
|
||||||
return val;
|
|
||||||
}));
|
|
||||||
}).catch((error: Error) => {
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public create(journalMember: JournalMemberJson): Promise<{}> {
|
|
||||||
const extra = {
|
|
||||||
method: 'post',
|
|
||||||
body: JSON.stringify(journalMember),
|
|
||||||
};
|
|
||||||
|
|
||||||
return this.newCall([], extra);
|
|
||||||
}
|
|
||||||
|
|
||||||
public delete(journalMember: JournalMemberJson): Promise<{}> {
|
|
||||||
const extra = {
|
|
||||||
method: 'delete',
|
|
||||||
};
|
|
||||||
|
|
||||||
return this.newCall([journalMember.user, ''], extra);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UserInfoManager extends BaseManager {
|
|
||||||
constructor(credentials: Credentials, apiBase: string) {
|
|
||||||
super(credentials, apiBase, ['user', '']);
|
|
||||||
}
|
|
||||||
|
|
||||||
public fetch(owner: string): Promise<UserInfo> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this.newCall<UserInfoJson>([owner, '']).then((json) => {
|
|
||||||
const userInfo = new UserInfo(owner, json.version);
|
|
||||||
userInfo.deserialize(json);
|
|
||||||
resolve(userInfo);
|
|
||||||
}).catch((error: Error) => {
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public create(userInfo: UserInfo): Promise<{}> {
|
|
||||||
const extra = {
|
|
||||||
method: 'post',
|
|
||||||
body: JSON.stringify(userInfo.serialize()),
|
|
||||||
};
|
|
||||||
|
|
||||||
return this.newCall([], extra);
|
|
||||||
}
|
|
||||||
|
|
||||||
public update(userInfo: UserInfo): Promise<{}> {
|
|
||||||
const extra = {
|
|
||||||
method: 'put',
|
|
||||||
body: JSON.stringify(userInfo.serialize()),
|
|
||||||
};
|
|
||||||
|
|
||||||
return this.newCall([userInfo.owner, ''], extra);
|
|
||||||
}
|
|
||||||
|
|
||||||
public delete(userInfo: UserInfo): Promise<{}> {
|
|
||||||
const extra = {
|
|
||||||
method: 'delete',
|
|
||||||
};
|
|
||||||
|
|
||||||
return this.newCall([userInfo.owner, ''], extra);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,11 +0,0 @@
|
|||||||
export type byte = number;
|
|
||||||
export type base64 = string;
|
|
||||||
|
|
||||||
export function stringToByteArray(str: string): byte[] {
|
|
||||||
const ret = [];
|
|
||||||
for (let i = 0 ; i < str.length ; i++) {
|
|
||||||
ret.push(str.charCodeAt(i));
|
|
||||||
}
|
|
||||||
|
|
||||||
return ret;
|
|
||||||
}
|
|
@ -1,6 +0,0 @@
|
|||||||
export const USER = 'test@localhost';
|
|
||||||
export const PASSWORD = 'SomePassword';
|
|
||||||
export const keyBase64 =
|
|
||||||
'Gpn6j6WJ/9JJbVkWhmEfZjlqSps5rwEOzjUOO0rqufvb4vtT4UfRgx0uMivuGwjF7/8Y1z1glIASX7Oz/4l2jucgf+lAzg2oTZFodWkXRZCDmFa7c9' +
|
|
||||||
'a8/04xIs7koFmUH34Rl9XXW6V2/GDVigQhQU8uWnrGo795tupoNQMbtB8RgMX5GyuxR55FvcybHpYBbwrDIsKvXcBxWFEscdNU8zyeq3yjvDo/W/y2' +
|
|
||||||
'4dApW3mnNo7vswoL2rpkZj3dqw==';
|
|
Loading…
Reference in New Issue