diff --git a/.eslintignore b/.eslintignore
index c3446d5d0..65b9ea36c 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -11,6 +11,7 @@ js/libloki.js
js/util_worker.js
js/libsignal-protocol-worker.js
libtextsecure/components.js
+libloki/test/test.js
libtextsecure/test/test.js
test/test.js
@@ -25,4 +26,3 @@ test/blanket_mocha.js
# TypeScript generated files
ts/**/*.js
-
diff --git a/.gitignore b/.gitignore
index 586046d86..cd183624e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,6 +22,7 @@ js/libtextsecure.js
js/libloki.js
libtextsecure/components.js
libtextsecure/test/test.js
+libloki/test/test.js
stylesheets/*.css
test/test.js
diff --git a/Gruntfile.js b/Gruntfile.js
index 5c392b946..5b521683f 100644
--- a/Gruntfile.js
+++ b/Gruntfile.js
@@ -91,6 +91,14 @@ module.exports = grunt => {
src: ['libloki/libloki-protocol.js'],
dest: 'js/libloki.js',
},
+ lokitest: {
+ src: [
+ 'node_modules/mocha/mocha.js',
+ 'node_modules/chai/chai.js',
+ 'libloki/test/_test.js',
+ ],
+ dest: 'libloki/test/test.js',
+ },
libtextsecuretest: {
src: [
'node_modules/jquery/dist/jquery.js',
@@ -355,6 +363,17 @@ module.exports = grunt => {
}
);
+ grunt.registerTask(
+ 'loki-unit-tests',
+ 'Run loki unit tests w/Electron',
+ function thisNeeded() {
+ const environment = grunt.option('env') || 'test-loki';
+ const done = this.async();
+
+ runTests(environment, done);
+ }
+ );
+
grunt.registerMultiTask(
'test-release',
'Test packaged releases',
@@ -442,7 +461,7 @@ module.exports = grunt => {
'locale-patch',
]);
grunt.registerTask('dev', ['default', 'watch']);
- grunt.registerTask('test', ['unit-tests', 'lib-unit-tests']);
+ grunt.registerTask('test', ['unit-tests', 'lib-unit-tests', 'loki-unit-tests']);
grunt.registerTask('date', ['gitinfo', 'getExpireTime']);
grunt.registerTask('default', [
'exec:build-protobuf',
diff --git a/libloki/proof-of-work.js b/libloki/proof-of-work.js
index 494c4987a..2034919f4 100644
--- a/libloki/proof-of-work.js
+++ b/libloki/proof-of-work.js
@@ -2,9 +2,19 @@ const hash = require('js-sha512');
const bb = require('bytebuffer');
const { BigInteger } = require('jsbn');
+module.exports = {
+ calcTarget,
+ incrementNonce,
+ bufferToBase64,
+ bigIntToUint8Array,
+ greaterThan,
+};
+
const NONCE_LEN = 8;
// Modify this value for difficulty scaling
-let NONCE_TRIALS = 1000;
+const DEV_NONCE_TRIALS = 10;
+const PROD_NONCE_TRIALS = 1000;
+let development = true;
// Increment Uint8Array nonce by 1 with carrying
function incrementNonce(nonce) {
@@ -62,27 +72,7 @@ function calcPoW(timestamp, ttl, pubKey, data) {
bb.wrap(timestamp.toString() + ttl.toString() + pubKey + data, 'binary').toArrayBuffer()
);
- // payloadLength + NONCE_LEN
- const totalLen = new BigInteger(payload.length.toString()).add(
- new BigInteger(NONCE_LEN.toString())
- );
- // ttl * totalLen
- const ttlMult = new BigInteger(ttl.toString()).multiply(totalLen);
- // ttlMult / (2^16 - 1)
- const innerFrac = ttlMult.divide(
- new BigInteger('2').pow(16).subtract(new BigInteger('1'))
- );
- // totalLen + innerFrac
- const lenPlusInnerFrac = totalLen.add(innerFrac);
- // NONCE_TRIALS * lenPlusInnerFrac
- const denominator = new BigInteger(NONCE_TRIALS.toString()).multiply(
- lenPlusInnerFrac
- );
- // 2^64 - 1
- const two64 = new BigInteger('2').pow(64).subtract(new BigInteger('1'));
- // two64 / denominator
- const targetNum = two64.divide(denominator);
- const target = bigIntToUint8Array(targetNum);
+ const target = calcTarget(ttl, payload.length);
let nonce = new Uint8Array(NONCE_LEN);
let trialValue = bigIntToUint8Array(
@@ -105,10 +95,34 @@ function calcPoW(timestamp, ttl, pubKey, data) {
return bufferToBase64(nonce);
}
+function calcTarget(ttl, payloadLen) {
+ // payloadLength + NONCE_LEN
+ const totalLen = new BigInteger(payloadLen.toString()).add(
+ new BigInteger(NONCE_LEN.toString())
+ );
+ // ttl * totalLen
+ const ttlMult = new BigInteger(ttl.toString()).multiply(totalLen);
+ // ttlMult / (2^16 - 1)
+ const innerFrac = ttlMult.divide(
+ new BigInteger('2').pow(16).subtract(new BigInteger('1'))
+ );
+ // totalLen + innerFrac
+ const lenPlusInnerFrac = totalLen.add(innerFrac);
+ // nonceTrials * lenPlusInnerFrac
+ const nonceTrials = development ? DEV_NONCE_TRIALS : PROD_NONCE_TRIALS;
+ const denominator = new BigInteger(nonceTrials.toString()).multiply(
+ lenPlusInnerFrac
+ );
+ // 2^64 - 1
+ const two64 = new BigInteger('2').pow(64).subtract(new BigInteger('1'));
+ // two64 / denominator
+ const targetNum = two64.divide(denominator);
+ return bigIntToUint8Array(targetNum);
+}
+
// Start calculation in child process when main process sends message data
process.on('message', msg => {
- if (msg.development)
- NONCE_TRIALS = 10;
+ ({ development } = msg);
process.send({
nonce: calcPoW(
msg.timestamp,
diff --git a/libloki/test/.eslintrc b/libloki/test/.eslintrc
new file mode 100644
index 000000000..194818b3a
--- /dev/null
+++ b/libloki/test/.eslintrc
@@ -0,0 +1,18 @@
+{
+ "env": {
+ "browser": true,
+ "node": false,
+ "mocha": true
+ },
+ "parserOptions": {
+ "sourceType": "script"
+ },
+ "rules": {
+ "strict": "off",
+ "more/no-then": "off"
+ },
+ "globals": {
+ "assert": true,
+ "clearDatabase": true
+ }
+}
diff --git a/libloki/test/_test.js b/libloki/test/_test.js
new file mode 100644
index 000000000..e26c8ab40
--- /dev/null
+++ b/libloki/test/_test.js
@@ -0,0 +1,54 @@
+/* global window, mocha, chai, assert, Whisper */
+
+mocha.setup('bdd');
+window.assert = chai.assert;
+window.PROTO_ROOT = '../../protos';
+
+const OriginalReporter = mocha._reporter;
+
+const SauceReporter = function Constructor(runner) {
+ const failedTests = [];
+
+ runner.on('end', () => {
+ window.mochaResults = runner.stats;
+ window.mochaResults.reports = failedTests;
+ });
+
+ runner.on('fail', (test, err) => {
+ const flattenTitles = item => {
+ const titles = [];
+ while (item.parent.title) {
+ titles.push(item.parent.title);
+ // eslint-disable-next-line no-param-reassign
+ item = item.parent;
+ }
+ return titles.reverse();
+ };
+ failedTests.push({
+ name: test.title,
+ result: false,
+ message: err.message,
+ stack: err.stack,
+ titles: flattenTitles(test),
+ });
+ });
+
+ // eslint-disable-next-line no-new
+ new OriginalReporter(runner);
+};
+
+SauceReporter.prototype = OriginalReporter.prototype;
+
+mocha.reporter(SauceReporter);
+
+// Override the database id.
+window.Whisper = window.Whisper || {};
+window.Whisper.Database = window.Whisper.Database || {};
+Whisper.Database.id = 'test';
+
+/*
+ * global helpers for tests
+ */
+window.clearDatabase = async () => {
+ await window.Signal.Data.removeAll();
+};
diff --git a/libloki/test/index.html b/libloki/test/index.html
new file mode 100644
index 000000000..2245c97e2
--- /dev/null
+++ b/libloki/test/index.html
@@ -0,0 +1,39 @@
+
+
+
+
+ libloki test runner
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/libloki/test/libloki-protocol_test.js b/libloki/test/libloki-protocol_test.js
new file mode 100644
index 000000000..5d0d6d11f
--- /dev/null
+++ b/libloki/test/libloki-protocol_test.js
@@ -0,0 +1,102 @@
+/* global libsignal, libloki, textsecure, StringView */
+
+'use strict';
+
+describe('FallBackSessionCipher', () => {
+ let fallbackCipher;
+ let identityKey;
+ let address;
+ const store = textsecure.storage.protocol;
+
+ before(async () => {
+ clearDatabase();
+ identityKey = await libsignal.KeyHelper.generateIdentityKeyPair();
+ store.put('identityKey', identityKey);
+ const key = libsignal.crypto.getRandomBytes(32);
+ const pubKeyString = StringView.arrayBufferToHex(key);
+ address = new libsignal.SignalProtocolAddress(
+ pubKeyString,
+ 1
+ );
+ fallbackCipher = new libloki.FallBackSessionCipher(address);
+ });
+
+ it('should encrypt fallback cipher messages as friend requests', async () => {
+ const buffer = new ArrayBuffer(10);
+ const { type } = await fallbackCipher.encrypt(buffer);
+ assert.strictEqual(type, textsecure.protobuf.Envelope.Type.FRIEND_REQUEST);
+ });
+
+ it('should encrypt and then decrypt a message with the same result', async () => {
+ const arr = new Uint8Array([1,2,3,4,5]);
+ const { body } = await fallbackCipher.encrypt(arr.buffer);
+ const result = await fallbackCipher.decrypt(body);
+ assert.deepEqual(result, arr.buffer);
+ });
+});
+
+describe('LibLoki Protocol', () => {
+ let testKey;
+ const store = textsecure.storage.protocol;
+
+ beforeEach(async () => {
+ clearDatabase();
+ testKey = {
+ pubKey: libsignal.crypto.getRandomBytes(33),
+ privKey: libsignal.crypto.getRandomBytes(32),
+ };
+ await store.storeSignedPreKey(1, testKey);
+ });
+
+ it('should generate a new prekey bundle for a new contact', async () => {
+ const pubKey = libsignal.crypto.getRandomBytes(32);
+ const pubKeyString = StringView.arrayBufferToHex(pubKey);
+ const preKeyIdBefore = textsecure.storage.get('maxPreKeyId', 1);
+ const newBundle = await libloki.getPreKeyBundleForContact(pubKeyString);
+ const preKeyIdAfter = textsecure.storage.get('maxPreKeyId', 1);
+ assert.strictEqual(preKeyIdAfter, preKeyIdBefore + 1);
+
+ const testKeyArray = new Uint8Array(testKey.pubKey);
+ assert.isDefined(newBundle);
+ assert.isDefined(newBundle.identityKey);
+ assert.isDefined(newBundle.deviceId);
+ assert.isDefined(newBundle.preKeyId);
+ assert.isDefined(newBundle.signedKeyId);
+ assert.isDefined(newBundle.preKey);
+ assert.isDefined(newBundle.signedKey);
+ assert.isDefined(newBundle.signature);
+ assert.strictEqual(testKeyArray.byteLength, newBundle.signedKey.byteLength);
+ for (let i = 0 ; i !== testKeyArray.byteLength; i += 1)
+ assert.strictEqual(testKeyArray[i], newBundle.signedKey[i]);
+ });
+
+ it('should return the same prekey bundle after creating a contact', async () => {
+ const pubKey = libsignal.crypto.getRandomBytes(32);
+ const pubKeyString = StringView.arrayBufferToHex(pubKey);
+ const bundle1 = await libloki.getPreKeyBundleForContact(pubKeyString);
+ const bundle2 = await libloki.getPreKeyBundleForContact(pubKeyString);
+ assert.isDefined(bundle1);
+ assert.isDefined(bundle2);
+ assert.deepEqual(bundle1, bundle2);
+ });
+
+ it('should save the signed keys and prekeys from a bundle', async () => {
+ const pubKey = libsignal.crypto.getRandomBytes(32);
+ const pubKeyString = StringView.arrayBufferToHex(pubKey);
+ const preKeyIdBefore = textsecure.storage.get('maxPreKeyId', 1);
+ const newBundle = await libloki.getPreKeyBundleForContact(pubKeyString);
+ const preKeyIdAfter = textsecure.storage.get('maxPreKeyId', 1);
+ assert.strictEqual(preKeyIdAfter, preKeyIdBefore + 1);
+
+ const testKeyArray = new Uint8Array(testKey.pubKey);
+ assert.isDefined(newBundle);
+ assert.isDefined(newBundle.identityKey);
+ assert.isDefined(newBundle.deviceId);
+ assert.isDefined(newBundle.preKeyId);
+ assert.isDefined(newBundle.signedKeyId);
+ assert.isDefined(newBundle.preKey);
+ assert.isDefined(newBundle.signedKey);
+ assert.isDefined(newBundle.signature);
+ assert.deepEqual(testKeyArray, newBundle.signedKey);
+ });
+});
diff --git a/libtextsecure/test/in_memory_signal_protocol_store.js b/libtextsecure/test/in_memory_signal_protocol_store.js
index 9b8ce813a..dcdb0d01a 100644
--- a/libtextsecure/test/in_memory_signal_protocol_store.js
+++ b/libtextsecure/test/in_memory_signal_protocol_store.js
@@ -75,7 +75,18 @@ SignalProtocolStore.prototype = {
resolve(res);
});
},
- storePreKey(keyId, keyPair) {
+ storePreKey(keyId, keyPair, contactPubKey = null) {
+ if (contactPubKey) {
+ const data = {
+ id: keyId,
+ publicKey: keyPair.pubKey,
+ privateKey: keyPair.privKey,
+ recipient: contactPubKey,
+ };
+ return new Promise(resolve => {
+ resolve(this.put(`25519KeypreKey${contactPubKey}`, data));
+ });
+ }
return new Promise(resolve => {
resolve(this.put(`25519KeypreKey${keyId}`, keyPair));
});
@@ -152,4 +163,43 @@ SignalProtocolStore.prototype = {
resolve(deviceIds);
});
},
+ async loadPreKeyForContact(contactPubKey) {
+ return new Promise(resolve => {
+ const key = this.get(`25519KeypreKey${contactPubKey}`);
+ if (!key) resolve(undefined);
+ resolve({
+ pubKey: key.publicKey,
+ privKey: key.privateKey,
+ keyId: key.id,
+ recipient: key.recipient,
+ });
+ });
+ },
+ async storeContactSignedPreKey(pubKey, signedPreKey) {
+ const key = {
+ identityKeyString: pubKey,
+ keyId: signedPreKey.keyId,
+ publicKey: signedPreKey.publicKey,
+ signature: signedPreKey.signature,
+ created_at: Date.now(),
+ confirmed: false,
+ };
+ this.put(`contactSignedPreKey${pubKey}`, key);
+ },
+ async loadContactSignedPreKey(pubKey) {
+ const preKey = this.get(`contactSignedPreKey${pubKey}`);
+ if (preKey) {
+ return {
+ id: preKey.id,
+ identityKeyString: preKey.identityKeyString,
+ publicKey: preKey.publicKey,
+ signature: preKey.signature,
+ created_at: preKey.created_at,
+ keyId: preKey.keyId,
+ confirmed: preKey.confirmed,
+ };
+ }
+ window.log.warn('Failed to fetch contact signed prekey:', pubKey);
+ return undefined;
+ },
};
diff --git a/main.js b/main.js
index 1e794c6da..62c41fc52 100644
--- a/main.js
+++ b/main.js
@@ -318,6 +318,10 @@ function createWindow() {
mainWindow.loadURL(
prepareURL([__dirname, 'libtextsecure', 'test', 'index.html'])
);
+ } else if (config.environment === 'test-loki') {
+ mainWindow.loadURL(
+ prepareURL([__dirname, 'libloki', 'test', 'index.html'])
+ );
} else {
mainWindow.loadURL(prepareURL([__dirname, 'background.html']));
}
@@ -341,6 +345,7 @@ function createWindow() {
if (
config.environment === 'test' ||
config.environment === 'test-lib' ||
+ config.environment === 'test-loki' ||
(mainWindow.readyForShutdown && windowState.shouldQuit())
) {
return;
@@ -611,7 +616,11 @@ app.on('ready', async () => {
const userDataPath = await getRealPath(app.getPath('userData'));
const installPath = await getRealPath(app.getAppPath());
- if (process.env.NODE_ENV !== 'test' && process.env.NODE_ENV !== 'test-lib') {
+ if (
+ process.env.NODE_ENV !== 'test' &&
+ process.env.NODE_ENV !== 'test-lib' &&
+ process.env.NODE_ENV !== 'test-loki'
+ ) {
installFileHandler({
protocol: electronProtocol,
userDataPath,
@@ -777,7 +786,8 @@ app.on('window-all-closed', () => {
if (
process.platform !== 'darwin' ||
config.environment === 'test' ||
- config.environment === 'test-lib'
+ config.environment === 'test-lib' ||
+ config.environment === 'test-loki'
) {
app.quit();
}
diff --git a/package.json b/package.json
index a0de59a19..cce536cd7 100644
--- a/package.json
+++ b/package.json
@@ -27,6 +27,9 @@
"prepare-import-build": "node prepare_import_build.js",
"publish-to-apt": "NAME=$npm_package_name VERSION=$npm_package_version ./aptly.sh",
"test": "yarn test-node && yarn test-electron",
+ "test-view": "NODE_ENV=test yarn run start",
+ "test-lib-view": "NODE_ENV=test-lib yarn run start",
+ "test-loki-view": "NODE_ENV=test-loki yarn run start",
"test-electron": "yarn grunt test",
"test-node": "mocha --recursive test/app test/modules ts/test",
"test-node-coverage": "nyc --reporter=lcov --reporter=text mocha --recursive test/app test/modules ts/test",
diff --git a/test/app/proof-of-work_test.js b/test/app/proof-of-work_test.js
new file mode 100644
index 000000000..311380548
--- /dev/null
+++ b/test/app/proof-of-work_test.js
@@ -0,0 +1,97 @@
+/* global require */
+const { assert } = require('chai');
+const { BigInteger } = require('jsbn');
+
+const {
+ calcTarget,
+ incrementNonce,
+ bufferToBase64,
+ bigIntToUint8Array,
+ greaterThan,
+} = require('../../libloki/proof-of-work');
+
+describe('Proof of Work Worker', () => {
+ it('should increment a Uint8Array nonce correctly', () => {
+ const arr1Before = new Uint8Array([0,0,0,0,0,0,0,0]);
+ const arr1After = incrementNonce(arr1Before);
+ assert.strictEqual(arr1After[0], 0);
+ assert.strictEqual(arr1After[1], 0);
+ assert.strictEqual(arr1After[2], 0);
+ assert.strictEqual(arr1After[3], 0);
+ assert.strictEqual(arr1After[4], 0);
+ assert.strictEqual(arr1After[5], 0);
+ assert.strictEqual(arr1After[6], 0);
+ assert.strictEqual(arr1After[7], 1);
+ });
+
+ it('should increment a Uint8Array nonce correctly', () => {
+ let arr = new Uint8Array([0,0,0,0,0,0,0,0]);
+ assert.deepEqual(incrementNonce(arr), new Uint8Array([0,0,0,0,0,0,0,1]));
+ arr = new Uint8Array([0,0,0,0,0,0,0,0]);
+ for(let i = 0; i <= 255; i += 1) {
+ arr = incrementNonce(arr);
+ }
+ assert.deepEqual(arr, new Uint8Array([0,0,0,0,0,0,1,0]));
+ arr = new Uint8Array([255,255,255,255,255,255,255,255]);
+ assert.deepEqual(incrementNonce(arr), new Uint8Array([0,0,0,0,0,0,0,0]));
+ });
+
+ it('should calculate a correct difficulty target', () => {
+ // These values will need to be updated if we adjust the difficulty settings
+ let payloadLen = 625;
+ const ttl = 86400;
+ let expectedTarget = new Uint8Array([0,4,119,164,35,224,222,64]);
+
+ let actualTarget = calcTarget(ttl, payloadLen);
+ assert.deepEqual(actualTarget, expectedTarget);
+ payloadLen = 6597;
+ expectedTarget = new Uint8Array([0,0,109,145,174,146,124,3]);
+ actualTarget = calcTarget(ttl, payloadLen);
+ assert.deepEqual(actualTarget, expectedTarget);
+ });
+
+ it('should correclty compare two Uint8Arrays', () => {
+ let arr1 = new Uint8Array([0,0,0,0,0,0,0,0,0,1]);
+ let arr2 = new Uint8Array([0,0,0,0,0,0,0,0,0,1]);
+ assert.isFalse(greaterThan(arr1, arr2))
+ arr1 = new Uint8Array([0,0,0,0,0,0,0,0,0,2]);
+ arr2 = new Uint8Array([0,0,0,0,0,0,0,0,0,1]);
+ assert.isTrue(greaterThan(arr1, arr2))
+ arr1 = new Uint8Array([255,255,255,255,255,255,255,255,255,255]);
+ arr2 = new Uint8Array([255,255,255,255,255,255,255,255,255,254]);
+ assert.isTrue(greaterThan(arr1, arr2))
+ arr1 = new Uint8Array([254,255,255,255,255,255,255,255,255,255]);
+ arr2 = new Uint8Array([255,255,255,255,255,255,255,255,255,255]);
+ assert.isFalse(greaterThan(arr1, arr2));
+ arr1 = new Uint8Array([0]);
+ arr2 = new Uint8Array([0,0]);
+ assert.isFalse(greaterThan(arr1, arr2))
+ });
+
+ it('should correclty convert a Uint8Array to a base64 string', () => {
+ let arr = new Uint8Array([1,2,3]);
+ let expected = 'AQID';
+ assert.strictEqual(bufferToBase64(arr), expected);
+ arr = new Uint8Array([123,25,3,121,45,87,24,111]);
+ expected = 'exkDeS1XGG8=';
+ assert.strictEqual(bufferToBase64(arr), expected);
+ arr = new Uint8Array([]);
+ expected = '';
+ assert.strictEqual(bufferToBase64(arr), expected);
+ });
+
+ it('should correclty convert a BigInteger to a Uint8Array', () => {
+ let bigInt = new BigInteger(Number.MAX_SAFE_INTEGER.toString());
+ let expected = new Uint8Array([0, 31, 255, 255, 255, 255, 255, 255]);
+ assert.deepEqual(bigIntToUint8Array(bigInt), expected);
+ bigInt = new BigInteger('0');
+ expected = new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0]);
+ assert.deepEqual(bigIntToUint8Array(bigInt), expected);
+ bigInt = new BigInteger('255');
+ expected = new Uint8Array([0, 0, 0, 0, 0, 0, 0, 255]);
+ assert.deepEqual(bigIntToUint8Array(bigInt), expected);
+ bigInt = new BigInteger('256');
+ expected = new Uint8Array([0, 0, 0, 0, 0, 0, 1, 0]);
+ assert.deepEqual(bigIntToUint8Array(bigInt), expected);
+ });
+});