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