diff --git a/spec/TestClient.js b/spec/TestClient.js index 1c08c2c7a..8a9d84bf7 100644 --- a/spec/TestClient.js +++ b/spec/TestClient.js @@ -1,7 +1,7 @@ /* Copyright 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd -Copyright 2018 New Vector Ltd +Copyright 2018-2019 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -38,9 +38,10 @@ import LocalStorageCryptoStore from '../lib/crypto/store/localStorage-crypto-sto * * @param {WebStorage=} sessionStoreBackend a web storage object to use for the * session store. If undefined, we will create a MockStorageApi. + * @param {object} options additional options to pass to the client */ export default function TestClient( - userId, deviceId, accessToken, sessionStoreBackend, + userId, deviceId, accessToken, sessionStoreBackend, options, ) { this.userId = userId; this.deviceId = deviceId; @@ -50,19 +51,22 @@ export default function TestClient( } const sessionStore = new sdk.WebStorageSessionStore(sessionStoreBackend); - // expose this so the tests can get to it - this.cryptoStore = new LocalStorageCryptoStore(sessionStoreBackend); - this.httpBackend = new MockHttpBackend(); - this.client = sdk.createClient({ + + options = Object.assign({ baseUrl: "http://" + userId + ".test.server", userId: userId, accessToken: accessToken, deviceId: deviceId, sessionStore: sessionStore, - cryptoStore: this.cryptoStore, request: this.httpBackend.requestFn, - }); + }, options); + if (!options.cryptoStore) { + // expose this so the tests can get to it + this.cryptoStore = new LocalStorageCryptoStore(sessionStoreBackend); + options.cryptoStore = this.cryptoStore; + } + this.client = sdk.createClient(options); this.deviceKeys = null; this.oneTimeKeys = {}; diff --git a/spec/unit/crypto/verification/qr_code.spec.js b/spec/unit/crypto/verification/qr_code.spec.js new file mode 100644 index 000000000..c655a9fbf --- /dev/null +++ b/spec/unit/crypto/verification/qr_code.spec.js @@ -0,0 +1,146 @@ +/* +Copyright 2018-2019 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +try { + global.Olm = require('olm'); +} catch (e) { + console.warn("unable to run device verification tests: libolm not available"); +} + +import expect from 'expect'; + +import DeviceInfo from '../../../../lib/crypto/deviceinfo'; + +import {ShowQRCode, ScanQRCode} from '../../../../lib/crypto/verification/QRCode'; + +const Olm = global.Olm; + +describe("QR code verification", function() { + if (!global.Olm) { + console.warn('Not running device verification tests: libolm not present'); + return; + } + + beforeEach(async function() { + await Olm.init(); + }); + + describe("showing", function() { + it("should emit an event to show a QR code", async function() { + const qrCode = new ShowQRCode({ + getUserId: () => "@alice:example.com", + deviceId: "ABCDEFG", + getDeviceEd25519Key: function() { + return "device+ed25519+key"; + }, + }); + const spy = expect.createSpy().andCall((e) => { + qrCode.done(); + }); + qrCode.on("show_qr_code", spy); + await qrCode.verify(); + expect(spy).toHaveBeenCalledWith({ + url: "https://matrix.to/#/@alice:example.com?device=ABCDEFG" + + "&action=verify&key_ed25519%3AABCDEFG=device%2Bed25519%2Bkey", + }); + }); + }); + + describe("scanning", function() { + const QR_CODE_URL = "https://matrix.to/#/@alice:example.com?device=ABCDEFG" + + "&action=verify&key_ed25519%3AABCDEFG=device%2Bed25519%2Bkey"; + it("should verify when a QR code is sent", async function() { + const device = DeviceInfo.fromStorage( + { + algorithms: [], + keys: { + "curve25519:ABCDEFG": "device+curve25519+key", + "ed25519:ABCDEFG": "device+ed25519+key", + }, + verified: false, + known: false, + unsigned: {}, + }, + "ABCDEFG", + ); + const client = { + getStoredDevice: expect.createSpy().andReturn(device), + setDeviceVerified: expect.createSpy(), + }; + const qrCode = new ScanQRCode(client); + qrCode.on("confirm_user_id", ({userId, confirm}) => { + if (userId === "@alice:example.com") { + confirm(); + } else { + qrCode.cancel(new Error("Incorrect user")); + } + }); + qrCode.on("scan", ({done}) => { + done(QR_CODE_URL); + }); + await qrCode.verify(); + expect(client.getStoredDevice) + .toHaveBeenCalledWith("@alice:example.com", "ABCDEFG"); + expect(client.setDeviceVerified) + .toHaveBeenCalledWith("@alice:example.com", "ABCDEFG"); + }); + + it("should error when the user ID doesn't match", async function() { + const client = { + getStoredDevice: expect.createSpy(), + setDeviceVerified: expect.createSpy(), + }; + const qrCode = new ScanQRCode(client, "@bob:example.com", "ABCDEFG"); + qrCode.on("scan", ({done}) => { + done(QR_CODE_URL); + }); + const spy = expect.createSpy(); + await qrCode.verify().catch(spy); + expect(spy).toHaveBeenCalled(); + expect(client.getStoredDevice).toNotHaveBeenCalled(); + expect(client.setDeviceVerified).toNotHaveBeenCalled(); + }); + + it("should error if the key doesn't match", async function() { + const device = DeviceInfo.fromStorage( + { + algorithms: [], + keys: { + "curve25519:ABCDEFG": "device+curve25519+key", + "ed25519:ABCDEFG": "a+different+device+ed25519+key", + }, + verified: false, + known: false, + unsigned: {}, + }, + "ABCDEFG", + ); + const client = { + getStoredDevice: expect.createSpy().andReturn(device), + setDeviceVerified: expect.createSpy(), + }; + const qrCode = new ScanQRCode(client, "@alice:example.com", "ABCDEFG"); + qrCode.on("scan", ({done}) => { + done(QR_CODE_URL); + }); + const spy = expect.createSpy(); + await qrCode.verify().catch(spy); + expect(spy).toHaveBeenCalled(); + expect(client.getStoredDevice).toHaveBeenCalled(); + expect(client.setDeviceVerified).toNotHaveBeenCalled(); + }); + }); +}); diff --git a/spec/unit/crypto/verification/request.spec.js b/spec/unit/crypto/verification/request.spec.js new file mode 100644 index 000000000..20bca562a --- /dev/null +++ b/spec/unit/crypto/verification/request.spec.js @@ -0,0 +1,75 @@ +/* +Copyright 2019 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +try { + global.Olm = require('olm'); +} catch (e) { + console.warn("unable to run device verification tests: libolm not available"); +} + +import expect from 'expect'; + +import {verificationMethods} from '../../../../lib/crypto'; + +import SAS from '../../../../lib/crypto/verification/SAS'; + +const Olm = global.Olm; + +import {makeTestClients} from './util'; + +describe("verification request", function() { + if (!global.Olm) { + console.warn('Not running device verification unit tests: libolm not present'); + return; + } + + beforeEach(async function() { + await Olm.init(); + }); + + it("should request and accept a verification", async function() { + const [alice, bob] = await makeTestClients( + [ + {userId: "@alice:example.com", deviceId: "Osborne2"}, + {userId: "@bob:example.com", deviceId: "Dynabook"}, + ], + { + verificationMethods: [verificationMethods.SAS], + }, + ); + alice._crypto._deviceList.getRawStoredDevicesForUser = function() { + return { + Dynabook: { + keys: { + "ed25519:Dynabook": "bob+base64+ed25519+key", + }, + }, + }; + }; + alice.downloadKeys = () => { + return Promise.resolve(); + }; + bob.downloadKeys = () => { + return Promise.resolve(); + }; + bob.on("crypto.verification.request", (request) => { + const bobVerifier = request.beginKeyVerification(verificationMethods.SAS); + bobVerifier.verify(); + }); + const aliceVerifier = await alice.requestVerification("@bob:example.com"); + expect(aliceVerifier).toBeAn(SAS); + }); +}); diff --git a/spec/unit/crypto/verification/sas.spec.js b/spec/unit/crypto/verification/sas.spec.js new file mode 100644 index 000000000..37362d42d --- /dev/null +++ b/spec/unit/crypto/verification/sas.spec.js @@ -0,0 +1,197 @@ +/* +Copyright 2018-2019 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +try { + global.Olm = require('olm'); +} catch (e) { + console.warn("unable to run device verification tests: libolm not available"); +} + +import expect from 'expect'; + +import sdk from '../../../..'; + +import {verificationMethods} from '../../../../lib/crypto'; +import DeviceInfo from '../../../../lib/crypto/deviceinfo'; + +import SAS from '../../../../lib/crypto/verification/SAS'; + +const Olm = global.Olm; + +const MatrixEvent = sdk.MatrixEvent; + +import {makeTestClients} from './util'; + +describe("SAS verification", function() { + if (!global.Olm) { + console.warn('Not running device verification unit tests: libolm not present'); + return; + } + + beforeEach(async function() { + await Olm.init(); + }); + + it("should error on an unexpected event", async function() { + const sas = new SAS({}, "@alice:example.com", "ABCDEFG"); + sas.handleEvent(new MatrixEvent({ + sender: "@alice:example.com", + type: "es.inquisition", + content: {}, + })); + const spy = expect.createSpy(); + await sas.verify() + .catch(spy); + expect(spy).toHaveBeenCalled(); + }); + + it("should verify a key", async function() { + const [alice, bob] = await makeTestClients( + [ + {userId: "@alice:example.com", deviceId: "Osborne2"}, + {userId: "@bob:example.com", deviceId: "Dynabook"}, + ], + { + verificationMethods: [verificationMethods.SAS], + }, + ); + + alice.setDeviceVerified = expect.createSpy(); + alice.getDeviceEd25519Key = () => { + return "alice+base64+ed25519+key"; + }; + alice.getStoredDevice = () => { + return DeviceInfo.fromStorage( + { + keys: { + "ed25519:Dynabook": "bob+base64+ed25519+key", + }, + }, + "Dynabook", + ); + }; + alice.downloadKeys = () => { + return Promise.resolve(); + }; + + bob.setDeviceVerified = expect.createSpy(); + bob.getStoredDevice = () => { + return DeviceInfo.fromStorage( + { + keys: { + "ed25519:Osborne2": "alice+base64+ed25519+key", + }, + }, + "Osborne2", + ); + }; + bob.getDeviceEd25519Key = () => { + return "bob+base64+ed25519+key"; + }; + bob.downloadKeys = () => { + return Promise.resolve(); + }; + + let aliceSasEvent; + let bobSasEvent; + + const bobPromise = new Promise((resolve, reject) => { + bob.on("crypto.verification.start", (verifier) => { + verifier.on("show_sas", (e) => { + if (!aliceSasEvent) { + bobSasEvent = e; + } else if (e.sas === aliceSasEvent.sas) { + e.confirm(); + aliceSasEvent.confirm(); + } else { + e.mismatch(); + aliceSasEvent.mismatch(); + } + }); + resolve(verifier); + }); + }); + + const aliceVerifier = alice.beginKeyVerification( + verificationMethods.SAS, bob.getUserId(), bob.deviceId, + ); + aliceVerifier.on("show_sas", (e) => { + if (!bobSasEvent) { + aliceSasEvent = e; + } else if (e.sas === bobSasEvent.sas) { + e.confirm(); + bobSasEvent.confirm(); + } else { + e.mismatch(); + bobSasEvent.mismatch(); + } + }); + await Promise.all([ + aliceVerifier.verify(), + bobPromise.then((verifier) => verifier.verify()), + ]); + expect(alice.setDeviceVerified) + .toHaveBeenCalledWith(bob.getUserId(), bob.deviceId); + expect(bob.setDeviceVerified) + .toHaveBeenCalledWith(alice.getUserId(), alice.deviceId); + }); + + it("should send a cancellation message on error", async function() { + const [alice, bob] = await makeTestClients( + [ + {userId: "@alice:example.com", deviceId: "Osborne2"}, + {userId: "@bob:example.com", deviceId: "Dynabook"}, + ], + { + verificationMethods: [verificationMethods.SAS], + }, + ); + alice.setDeviceVerified = expect.createSpy(); + alice.downloadKeys = () => { + return Promise.resolve(); + }; + bob.setDeviceVerified = expect.createSpy(); + bob.downloadKeys = () => { + return Promise.resolve(); + }; + + const bobPromise = new Promise((resolve, reject) => { + bob.on("crypto.verification.start", (verifier) => { + verifier.on("show_sas", (e) => { + e.mismatch(); + }); + resolve(verifier); + }); + }); + + const aliceVerifier = alice.beginKeyVerification( + verificationMethods.SAS, bob.getUserId(), bob.deviceId, + ); + + const aliceSpy = expect.createSpy(); + const bobSpy = expect.createSpy(); + await Promise.all([ + aliceVerifier.verify().catch(aliceSpy), + bobPromise.then((verifier) => verifier.verify()).catch(bobSpy), + ]); + expect(aliceSpy).toHaveBeenCalled(); + expect(bobSpy).toHaveBeenCalled(); + expect(alice.setDeviceVerified) + .toNotHaveBeenCalled(); + expect(bob.setDeviceVerified) + .toNotHaveBeenCalled(); + }); +}); diff --git a/spec/unit/crypto/verification/util.js b/spec/unit/crypto/verification/util.js new file mode 100644 index 000000000..faa9c58f4 --- /dev/null +++ b/spec/unit/crypto/verification/util.js @@ -0,0 +1,63 @@ +/* +Copyright 2019 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import TestClient from '../../../TestClient'; + +import sdk from '../../../..'; +const MatrixEvent = sdk.MatrixEvent; + +export async function makeTestClients(userInfos, options) { + const clients = []; + const clientMap = {}; + const sendToDevice = function(type, map) { + // console.log(this.getUserId(), "sends", type, map); + for (const [userId, devMap] of Object.entries(map)) { + if (userId in clientMap) { + for (const [deviceId, msg] of Object.entries(devMap)) { + if (deviceId in clientMap[userId]) { + const event = new MatrixEvent({ + sender: this.getUserId(), // eslint-disable-line no-invalid-this + type: type, + content: msg, + }); + setTimeout( + () => clientMap[userId][deviceId] + .emit("toDeviceEvent", event), + 0, + ); + } + } + } + } + }; + + for (const userInfo of userInfos) { + const client = (new TestClient( + userInfo.userId, userInfo.deviceId, undefined, undefined, + options, + )).client; + if (!(userInfo.userId in clientMap)) { + clientMap[userInfo.userId] = {}; + } + clientMap[userInfo.userId][userInfo.deviceId] = client; + client.sendToDevice = sendToDevice; + clients.push(client); + } + + await Promise.all(clients.map((client) => client.initCrypto())); + + return clients; +} diff --git a/src/client.js b/src/client.js index 38779d0cb..43c1b8761 100644 --- a/src/client.js +++ b/src/client.js @@ -1,7 +1,7 @@ /* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd -Copyright 2018 New Vector Ltd +Copyright 2018-2019 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -142,6 +142,11 @@ function keyFromRecoverySession(session, decryptionKey) { * * @param {module:crypto.store.base~CryptoStore} opts.cryptoStore * crypto store implementation. + * + * @param {Array} [opts.verificationMethods] Optional. The verification method + * that the application can handle. Each element should be an item from {@link + * module:crypto~verificationMethods verificationMethods}, or a class that + * implements the {$link module:crypto/verification/Base verifier interface}. */ function MatrixClient(opts) { // Allow trailing slash in HS url @@ -207,6 +212,7 @@ function MatrixClient(opts) { this._crypto = null; this._cryptoStore = opts.cryptoStore; this._sessionStore = opts.sessionStore; + this._verificationMethods = opts.verificationMethods; this._forceTURN = opts.forceTURN || false; @@ -444,6 +450,7 @@ MatrixClient.prototype.initCrypto = async function() { this.store, this._cryptoStore, this._roomList, + this._verificationMethods, ); this.reEmitter.reEmit(crypto, [ @@ -625,6 +632,43 @@ async function _setDeviceVerification( client.emit("deviceVerificationChanged", userId, deviceId, dev); } +/** + * Request a key verification from another user. + * + * @param {string} userId the user to request verification with + * @param {Array} devices array of device IDs to send requests to. Defaults to + * all devices owned by the user + * @param {Array} methods array of verification methods to use. Defaults to + * all known methods + * + * @returns {Promise} resolves to a verifier + * when the request is accepted by the other user + */ +MatrixClient.prototype.requestVerification = function(userId, devices, methods) { + if (this._crypto === null) { + throw new Error("End-to-end encryption disabled"); + } + return this._crypto.requestVerification(userId, devices); +}; + +/** + * Begin a key verification. + * + * @param {string} method the verification method to use + * @param {string} userId the user to verify keys with + * @param {string} deviceId the device to verify + * + * @returns {module:crypto/verification/Base} a verification object + */ +MatrixClient.prototype.beginKeyVerification = function( + method, userId, deviceId, +) { + if (this._crypto === null) { + throw new Error("End-to-end encryption disabled"); + } + return this._crypto.beginKeyVerification(method, userId, deviceId); +}; + /** * Set the global override for whether the client should ever send encrypted * messages to unverified devices. This provides the default for rooms which @@ -4162,6 +4206,34 @@ module.exports.CRYPTO_ENABLED = CRYPTO_ENABLED; * @event module:client~MatrixClient#"crypto.suggestKeyRestore" */ +/** + * Fires when a key verification is requested. + * @event module:client~MatrixClient#"crypto.verification.request" + * @param {object} data + * @param {MatrixEvent} data.event the original verification request message + * @param {Array} data.methods the verification methods that can be used + * @param {Function} data.beginKeyVerification a function to call if a key + * verification should be performed. The function takes one argument: the + * name of the key verification method (taken from data.methods) to use. + * @param {Function} data.cancel a function to call if the key verification is + * rejected. + */ + +/** + * Fires when a key verification is requested with an unknown method. + * @event module:client~MatrixClient#"crypto.verification.request.unknown" + * @param {string} userId the user ID who requested the key verification + * @param {Function} cancel a function that will send a cancellation message to + * reject the key verification. + */ + +/** + * Fires when a key verification started message is received. + * @event module:client~MatrixClient#"crypto.verification.start" + * @param {module:crypto/verification/Base} verifier a verifier object to + * perform the key verification + */ + // EventEmitter JSDocs /** diff --git a/src/crypto/index.js b/src/crypto/index.js index e9a5684ee..0621226d1 100644 --- a/src/crypto/index.js +++ b/src/crypto/index.js @@ -1,7 +1,7 @@ /* Copyright 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd -Copyright 2018 New Vector Ltd +Copyright 2018-2019 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -33,10 +33,34 @@ const algorithms = require("./algorithms"); const DeviceInfo = require("./deviceinfo"); const DeviceVerification = DeviceInfo.DeviceVerification; const DeviceList = require('./DeviceList').default; +import { randomString } from '../randomstring'; import OutgoingRoomKeyRequestManager from './OutgoingRoomKeyRequestManager'; import IndexedDBCryptoStore from './store/indexeddb-crypto-store'; +import {ShowQRCode, ScanQRCode} from './verification/QRCode'; +import SAS from './verification/SAS'; +import { + newUserCancelledError, + newUnexpectedMessageError, + newUnknownMethodError, +} from './verification/Error'; + +const defaultVerificationMethods = { + [ScanQRCode.NAME]: ScanQRCode, + [ShowQRCode.NAME]: ShowQRCode, + [SAS.NAME]: SAS, +}; + +/** + * verification method names + */ +export const verificationMethods = { + QR_CODE_SCAN: ScanQRCode.NAME, + QR_CODE_SHOW: ShowQRCode.NAME, + SAS: SAS.NAME, +}; + export function isCryptoAvailable() { return Boolean(global.Olm); } @@ -69,9 +93,13 @@ const KEY_BACKUP_KEYS_PER_REQUEST = 200; * storage for the crypto layer. * * @param {RoomList} roomList An initialised RoomList object + * + * @param {Array} verificationMethods Array of verification methods to use. + * Each element can either be a string from MatrixClient.verificationMethods + * or a class that implements a verification method. */ export default function Crypto(baseApis, sessionStore, userId, deviceId, - clientStore, cryptoStore, roomList) { + clientStore, cryptoStore, roomList, verificationMethods) { this._baseApis = baseApis; this._sessionStore = sessionStore; this._userId = userId; @@ -79,6 +107,24 @@ export default function Crypto(baseApis, sessionStore, userId, deviceId, this._clientStore = clientStore; this._cryptoStore = cryptoStore; this._roomList = roomList; + this._verificationMethods = new Map(); + if (verificationMethods) { + for (const method of verificationMethods) { + if (typeof method === "string") { + if (defaultVerificationMethods[method]) { + this._verificationMethods.set( + method, + defaultVerificationMethods[method], + ); + } + } else if (method.NAME) { + this._verificationMethods.set( + method.NAME, + method, + ); + } + } + } // track whether this device's megolm keys are being backed up incrementally // to the server or not. @@ -140,6 +186,8 @@ export default function Crypto(baseApis, sessionStore, userId, deviceId, // }, // } this._lastNewSessionForced = {}; + + this._verificationTransactions = new Map(); } utils.inherits(Crypto, EventEmitter); @@ -673,6 +721,81 @@ Crypto.prototype.setDeviceVerification = async function( }; +Crypto.prototype.requestVerification = function(userId, methods, devices) { + if (!methods) { + // .keys() returns an iterator, so we need to explicitly turn it into an array + methods = [...this._verificationMethods.keys()]; + } + if (!devices) { + devices = Object.keys(this._deviceList.getRawStoredDevicesForUser(userId)); + } + if (!this._verificationTransactions.has(userId)) { + this._verificationTransactions.set(userId, new Map); + } + + const transactionId = randomString(32); + + const promise = new Promise((resolve, reject) => { + this._verificationTransactions.get(userId).set(transactionId, { + request: { + methods: methods, + devices: devices, + resolve: resolve, + reject: reject, + }, + }); + }); + + const message = { + transaction_id: transactionId, + from_device: this._baseApis.deviceId, + methods: methods, + timestamp: Date.now(), + }; + const msgMap = {}; + for (const deviceId of devices) { + msgMap[deviceId] = message; + } + this._baseApis.sendToDevice("m.key.verification.request", {[userId]: msgMap}); + + return promise; +}; + +Crypto.prototype.beginKeyVerification = function( + method, userId, deviceId, transactionId, +) { + if (!this._verificationTransactions.has(userId)) { + this._verificationTransactions.set(userId, new Map()); + } + transactionId = transactionId || randomString(32); + if (method instanceof Array) { + if (method.length !== 2 + || !this._verificationMethods.has(method[0]) + || !this._verificationMethods.has(method[1])) { + throw newUnknownMethodError(); + } + /* + return new TwoPartVerification( + this._verificationMethods[method[0]], + this._verificationMethods[method[1]], + userId, deviceId, transactionId, + ); + */ + } else if (this._verificationMethods.has(method)) { + const verifier = new (this._verificationMethods.get(method))( + this._baseApis, userId, deviceId, transactionId, + ); + if (!this._verificationTransactions.get(userId).has(transactionId)) { + this._verificationTransactions.get(userId).set(transactionId, {}); + } + this._verificationTransactions.get(userId).get(transactionId).verifier = verifier; + return verifier; + } else { + throw newUnknownMethodError(); + } +}; + + /** * Get information on the active olm sessions with a user *

@@ -1434,6 +1557,12 @@ Crypto.prototype._onToDeviceEvent = function(event) { this._onRoomKeyEvent(event); } else if (event.getType() == "m.room_key_request") { this._onRoomKeyRequestEvent(event); + } else if (event.getType() === "m.key.verification.request") { + this._onKeyVerificationRequest(event); + } else if (event.getType() === "m.key.verification.start") { + this._onKeyVerificationStart(event); + } else if (event.getContent().transaction_id) { + this._onKeyVerificationMessage(event); } else if (event.getContent().msgtype === "m.bad.encrypted") { this._onToDeviceBadEncrypted(event); } else if (event.isBeingDecrypted()) { @@ -1471,6 +1600,264 @@ Crypto.prototype._onRoomKeyEvent = function(event) { alg.onRoomKeyEvent(event); }; +/** + * Handle a key verification request event. + * + * @private + * @param {module:models/event.MatrixEvent} event verification request event + */ +Crypto.prototype._onKeyVerificationRequest = function(event) { + const content = event.getContent(); + if (!("from_device" in content) || typeof content.from_device !== "string" + || !("transaction_id" in content) || typeof content.from_device !== "string" + || !("methods" in content) || !(content.methods instanceof Array) + || !("timestamp" in content) || typeof content.timestamp !== "number") { + logger.warn("received invalid verification request from " + event.getSender()); + // ignore event if malformed + return; + } + + const now = Date.now(); + if (now < content.timestamp - (5 * 60 * 1000) + || now > content.timestamp + (10 * 60 * 1000)) { + // ignore if event is too far in the past or too far in the future + logger.log("received verification that is too old or from the future"); + return; + } + + const sender = event.getSender(); + if (this._verificationTransactions.has(sender)) { + if (this._verificationTransactions.get(sender).has(content.transaction_id)) { + // transaction already exists: cancel it and drop the existing + // request because someone has gotten confused + const err = newUnexpectedMessageError({ + transaction_id: content.transaction_id, + }); + if (this._verificationTransactions.get(sender).get(content.transaction_id) + .verifier) { + this._verificationTransactions.get(sender).get(content.transaction_id) + .verifier.cancel(err); + } else { + this._verificationTransactions.get(sender).get(content.transaction_id) + .reject(err); + this.sendToDevice("m.key.verification.cancel", { + [sender]: { + [content.from_device]: err.getContent(), + }, + }); + } + this._verificationTransactions.get(sender).delete(content.transaction_id); + return; + } + } else { + this._verificationTransactions.set(sender, new Map()); + } + + // determine what requested methods we support + const methods = []; + for (const method of content.methods) { + if (typeof method !== "string") { + continue; + } + if (this._verificationMethods.has(method)) { + methods.push(method); + } + } + if (methods.length === 0) { + this._baseApis.emit( + "crypto.verification.request.unknown", + event.getSender(), + () => { + this.sendToDevice("m.key.verification.cancel", { + [sender]: { + [content.from_device]: newUserCancelledError({ + transaction_id: content.transaction_id, + }).getContent(), + }, + }); + }, + ); + } else { + // notify the application that of the verification request, so it can + // decide what to do with it + const request = { + event: event, + methods: methods, + beginKeyVerification: (method) => { + const verifier = this.beginKeyVerification( + method, + sender, + content.from_device, + content.transaction_id, + ); + this._verificationTransactions.get(sender).get(content.transaction_id) + .verifier = verifier; + return verifier; + }, + cancel: () => { + this._baseApis.sendToDevice("m.key.verification.cancel", { + [sender]: { + [content.from_device]: newUserCancelledError({ + transaction_id: content.transaction_id, + }).getContent(), + }, + }); + }, + }; + this._verificationTransactions.get(sender).set(content.transaction_id, { + request: request, + }); + this._baseApis.emit("crypto.verification.request", request); + } +}; + +/** + * Handle a key verification start event. + * + * @private + * @param {module:models/event.MatrixEvent} event verification start event + */ +Crypto.prototype._onKeyVerificationStart = function(event) { + const sender = event.getSender(); + const content = event.getContent(); + const transactionId = content.transaction_id; + const deviceId = content.from_device; + if (!transactionId || !deviceId) { + // invalid request, and we don't have enough information to send a + // cancellation, so just ignore it + return; + } + + let handler = this._verificationTransactions.has(sender) + && this._verificationTransactions.get(sender).get(transactionId); + // if the verification start message is invalid, send a cancel message to + // the other side, and also send a cancellation event + const cancel = (err) => { + if (handler.verifier) { + handler.verifier.cancel(err); + } else if (handler.request && handler.request.cancel) { + handler.request.cancel(err); + } + this.sendToDevice( + "m.key.verification.cancel", { + [sender]: { + [deviceId]: err.getContent(), + }, + }, + ); + }; + if (!this._verificationMethods.has(content.method)) { + cancel(newUnknownMethodError({ + transaction_id: content.transactionId, + })); + return; + } else if (content.next_method) { + if (!this._verificationMethods.has(content.next_method)) { + cancel(newUnknownMethodError({ + transaction_id: content.transactionId, + })); + return; + } else { + /* TODO: + const verification = new TwoPartVerification( + this._verificationMethods[content.method], + this._verificationMethods[content.next_method], + userId, deviceId, + ); + this.emit(verification.event_type, verification); + this.emit(verification.first.event_type, verification);*/ + } + } else { + const verifier = new (this._verificationMethods.get(content.method))( + this._baseApis, sender, deviceId, content.transaction_id, + event, handler && handler.request, + ); + if (!handler) { + if (!this._verificationTransactions.has(sender)) { + this._verificationTransactions.set(sender, new Map()); + } + handler = this._verificationTransactions.get(sender).set(transactionId, { + verifier: verifier, + }); + } else { + if (!handler.verifier) { + handler.verifier = verifier; + if (handler.request) { + // the verification start was sent as a response to a + // verification request + + if (!handler.request.devices.includes(deviceId)) { + // didn't send a request to that device, so it + // shouldn't have responded + cancel(newUnexpectedMessageError({ + transaction_id: content.transactionId, + })); + return; + } + if (!handler.request.methods.includes(content.method)) { + // verification method wasn't one that was requested + cancel(newUnknownMethodError({ + transaction_id: content.transactionId, + })); + return; + } + + // send cancellation messages to all the other devices that + // the request was sent to + const message = { + transaction_id: transactionId, + code: "m.accepted", + reason: "Verification request accepted by another device", + }; + const msgMap = {}; + for (const devId of handler.request.devices) { + if (devId !== deviceId) { + msgMap[devId] = message; + } + } + this._baseApis.sendToDevice("m.key.verification.cancel", { + [sender]: msgMap, + }); + + handler.request.resolve(verifier); + } + } else { + // FIXME: make sure we're in a two-part verification, and the start matches the second part + } + } + this._baseApis.emit("crypto.verification.start", verifier); + } +}; + +/** + * Handle a general key verification event. + * + * @private + * @param {module:models/event.MatrixEvent} event verification start event + */ +Crypto.prototype._onKeyVerificationMessage = function(event) { + const sender = event.getSender(); + const transactionId = event.getContent().transaction_id; + const handler = this._verificationTransactions.has(sender) + && this._verificationTransactions.get(sender).get(transactionId); + if (!handler) { + return; + } else if (event.getType() === "m.key.verification.cancel") { + console.log(event); + if (handler.verifier) { + handler.verifier.cancel(event); + } else if (handler.request && handler.request.cancel) { + handler.request.cancel(event); + } + } else if (handler.verifier) { + const verifier = handler.verifier; + if (verifier.events + && verifier.events.includes(event.getType())) { + verifier.handleEvent(event); + } + } +}; + /** * Handle a toDevice event that couldn't be decrypted * diff --git a/src/crypto/verification/Base.js b/src/crypto/verification/Base.js new file mode 100644 index 000000000..36a538e79 --- /dev/null +++ b/src/crypto/verification/Base.js @@ -0,0 +1,200 @@ +/* +Copyright 2018 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Base class for verification methods. + * @module crypto/verification/Base + */ + +import {MatrixEvent} from '../../models/event'; +import {EventEmitter} from 'events'; + +export default class VerificationBase extends EventEmitter { + /** + * Base class for verification methods. + * + *

Once a verifier object is created, the verification can be started by + * calling the verify() method, which will return a promise that will + * resolve when the verification is completed, or reject if it could not + * complete.

+ * + *

Subclasses must have a NAME class property.

+ * + * @class + * + * @param {module:base-apis~MatrixBaseApis} baseApis base matrix api interface + * + * @param {string} userId the user ID that is being verified + * + * @param {string} deviceId the device ID that is being verified + * + * @param {string} transactionId the transaction ID to be used when sending events + * + * @param {object} startEvent the m.key.verification.start event that + * initiated this verification, if any + * + * @param {object} request the key verification request object related to + * this verification, if any + * + * @param {object} parent parent verification for this verification, if any + */ + constructor(baseApis, userId, deviceId, transactionId, startEvent, request, parent) { + super(); + this._baseApis = baseApis; + this.userId = userId; + this.deviceId = deviceId; + this.transactionId = transactionId; + this.startEvent = startEvent; + this.request = request; + this._parent = parent; + this._done = false; + this._promise = null; + } + + _sendToDevice(type, content) { + if (this._done) { + return Promise.reject(new Error("Verification is already done")); + } + content.transaction_id = this.transactionId; + return this._baseApis.sendToDevice(type, { + [this.userId]: { [this.deviceId]: content }, + }); + } + + _waitForEvent(type) { + if (this._done) { + return Promise.reject(new Error("Verification is already done")); + } + this._expectedEvent = type; + return new Promise((resolve, reject) => { + this._resolveEvent = resolve; + this._rejectEvent = reject; + }); + } + + handleEvent(e) { + if (this._done) { + return; + } else if (e.getType() === this._expectedEvent) { + this._expectedEvent = undefined; + this._rejectEvent = undefined; + this._resolveEvent(e); + } else { + this._expectedEvent = undefined; + const exception = new Error( + "Unexpected message: expecting " + this._expectedEvent + + " but got " + e.getType(), + ); + if (this._rejectEvent) { + const reject = this._rejectEvent; + this._rejectEvent = undefined; + reject(exception); + } + this.cancel(exception); + } + } + + done() { + if (!this._done) { + this._resolve(); + } + } + + cancel(e) { + if (!this._done) { + if (this.userId && this.deviceId && this.transactionId) { + // send a cancellation to the other user (if it wasn't + // cancelled by the other user) + if (e instanceof MatrixEvent) { + const sender = e.getSender(); + if (sender !== this.userId) { + const content = e.getContent(); + if (e.getType() === "m.key.verification.cancel") { + content.code = content.code || "m.unknown"; + content.reason = content.reason || content.body + || "Unknown reason"; + content.transaction_id = this.transactionId; + this._sendToDevice("m.key.verification.cancel", content); + } else { + this._sendToDevice("m.key.verification.cancel", { + code: "m.unknown", + reason: content.body || "Unknown reason", + transaction_id: this.transactionId, + }); + } + } + } else { + this._sendToDevice("m.key.verification.cancel", { + code: "m.unknown", + reason: e.toString(), + transaction_id: this.transactionId, + }); + } + } + if (this._promise !== null) { + this._reject(e); + } else { + this._promise = Promise.reject(e); + } + // Also emit a 'cancel' event that the app can listen for to detect cancellation + // before calling verify() + this.emit('cancel', e); + } + } + + /** + * Begin the key verification + * + * @returns {Promise} Promise which resolves when the verification has + * completed. + */ + verify() { + if (this._promise) return this._promise; + + this._promise = new Promise((resolve, reject) => { + this._resolve = (...args) => { + this._done = true; + resolve(...args); + }; + this._reject = (...args) => { + this._done = true; + reject(...args); + }; + }); + if (this._doVerification && !this._started) { + this._started = true; + Promise.resolve(this._doVerification()) + .then(this.done.bind(this), this.cancel.bind(this)); + } + return this._promise; + } + + async _verifyKeys(userId, keys, verifier) { + for (const [keyId, keyInfo] of Object.entries(keys)) { + const deviceId = keyId.split(':', 2)[1]; + const device = await this._baseApis.getStoredDevice(userId, deviceId); + if (!device) { + throw new Error(`Could not find device ${deviceId}`); + } else { + await verifier(keyId, device, keyInfo); + } + } + for (const keyId of Object.keys(keys)) { + const deviceId = keyId.split(':', 2)[1]; + await this._baseApis.setDeviceVerified(userId, deviceId); + } + } +} diff --git a/src/crypto/verification/Error.js b/src/crypto/verification/Error.js new file mode 100644 index 000000000..ae761a4d6 --- /dev/null +++ b/src/crypto/verification/Error.js @@ -0,0 +1,87 @@ +/* +Copyright 2018 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Error messages. + * + * @module crypto/verification/Error + */ + +import {MatrixEvent} from "../../models/event"; + +export function newVerificationError(code, reason, extradata) { + extradata = extradata || {}; + extradata.code = code; + extradata.reason = reason; + return new MatrixEvent({ + type: "m.key.verification.cancel", + content: extradata, + }); +} + +export function errorFactory(code, reason) { + return function(extradata) { + return newVerificationError(code, reason, extradata); + }; +} + +/** + * The verification was cancelled by the user. + */ +export const newUserCancelledError = errorFactory("m.user", "Cancelled by user"); + +/** + * The verification timed out. + */ +export const newTimeoutError = errorFactory("m.timeout", "Timed out"); + +/** + * The transaction is unknown. + */ +export const newUnknownTransactionError = errorFactory( + "m.unknown_transaction", "Unknown transaction", +); + +/** + * An unknown method was selected. + */ +export const newUnknownMethodError = errorFactory("m.unknown_method", "Unknown method"); + +/** + * An unexpected message was sent. + */ +export const newUnexpectedMessageError = errorFactory( + "m.unexpected_message", "Unexpected message", +); + +/** + * The key does not match. + */ +export const newKeyMismatchError = errorFactory( + "m.key_mismatch", "Key mismatch", +); + +/** + * The user does not match. + */ +export const newUserMismatchError = errorFactory("m.user_error", "User mismatch"); + +/** + * An invalid message was sent. + */ +export const newInvalidMessageError = errorFactory( + "m.invalid_message", "Invalid message", +); diff --git a/src/crypto/verification/QRCode.js b/src/crypto/verification/QRCode.js new file mode 100644 index 000000000..d86e389b3 --- /dev/null +++ b/src/crypto/verification/QRCode.js @@ -0,0 +1,123 @@ +/* +Copyright 2018 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * QR code key verification. + * @module crypto/verification/QRCode + */ + +import Base from "./Base"; +import { + errorFactory, + newUserCancelledError, + newKeyMismatchError, + newUserMismatchError, +} from './Error'; + +const MATRIXTO_REGEXP = /^(?:https?:\/\/)?(?:www\.)?matrix\.to\/#\/([#@!+][^?]+)\?(.+)$/; +const KEY_REGEXP = /^key_([^:]+:.+)$/; + +const newQRCodeError = errorFactory("m.qr_code.invalid", "Invalid QR code"); + +/** + * @class crypto/verification/QRCode/ShowQRCode + * @extends {module:crypto/verification/Base} + */ +export class ShowQRCode extends Base { + _doVerification() { + if (!this._done) { + const url = "https://matrix.to/#/" + this._baseApis.getUserId() + + "?device=" + encodeURIComponent(this._baseApis.deviceId) + + "&action=verify&key_ed25519%3A" + + encodeURIComponent(this._baseApis.deviceId) + "=" + + encodeURIComponent(this._baseApis.getDeviceEd25519Key()); + this.emit("show_qr_code", { + url: url, + }); + } + } +} + +ShowQRCode.NAME = "m.qr_code.show.v1"; + +/** + * @class crypto/verification/QRCode/ScanQRCode + * @extends {module:crypto/verification/Base} + */ +export class ScanQRCode extends Base { + static factory(...args) { + return new ScanQRCode(...args); + } + + async _doVerification() { + const code = await new Promise((resolve, reject) => { + this.emit("scan", { + done: resolve, + cancel: () => reject(newUserCancelledError()), + }); + }); + + const match = code.match(MATRIXTO_REGEXP); + let deviceId; + const keys = {}; + if (!match) { + throw newQRCodeError(); + } + const userId = match[1]; + const params = match[2].split("&").map( + (x) => x.split("=", 2).map(decodeURIComponent), + ); + let action; + for (const [name, value] of params) { + if (name === "device") { + deviceId = value; + } else if (name === "action") { + action = value; + } else { + const keyMatch = name.match(KEY_REGEXP); + if (keyMatch) { + keys[keyMatch[1]] = value; + } + } + } + if (!deviceId || action !== "verify" || Object.keys(keys).length === 0) { + throw newQRCodeError(); + } + + if (!this.userId) { + await new Promise((resolve, reject) => { + this.emit("confirm_user_id", { + userId: userId, + confirm: resolve, + cancel: () => reject(newUserMismatchError()), + }); + }); + } else if (this.userId !== userId) { + throw newUserMismatchError({ + expected: this.userId, + actual: userId, + }); + } + + await this._verifyKeys(userId, keys, (keyId, device, key) => { + if (device.keys[keyId] !== key) { + throw newKeyMismatchError(); + } + }); + } +} + +ScanQRCode.NAME = "m.qr_code.scan.v1"; diff --git a/src/crypto/verification/SAS.js b/src/crypto/verification/SAS.js new file mode 100644 index 000000000..9ef42d078 --- /dev/null +++ b/src/crypto/verification/SAS.js @@ -0,0 +1,252 @@ +/* +Copyright 2018 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Short Authentication String (SAS) verification. + * @module crypto/verification/SAS + */ + +import Base from "./Base"; +import anotherjson from 'another-json'; +import { + errorFactory, + newUserCancelledError, + newUnknownMethodError, + newKeyMismatchError, + newInvalidMessageError, +} from './Error'; + +const EVENTS = [ + "m.key.verification.accept", + "m.key.verification.key", + "m.key.verification.mac", +]; + +let olmutil; + +const newMismatchedSASError = errorFactory( + "m.mismatched_sas", "Mismatched short authentication string", +); + +const newMismatchedCommitmentError = errorFactory( + "m.mismatched_commitment", "Mismatched commitment", +); + +/** + * @alias module:crypto/verification/SAS + * @extends {module:crypto/verification/Base} + */ +export default class SAS extends Base { + get events() { + return EVENTS; + } + + async _doVerification() { + await global.Olm.init(); + olmutil = olmutil || new global.Olm.Utility(); + + // make sure user's keys are downloaded + await this._baseApis.downloadKeys([this.userId]); + + if (this.startEvent) { + return await this._doRespondVerification(); + } else { + return await this._doSendVerification(); + } + } + + async _doSendVerification() { + const initialMessage = { + method: SAS.NAME, + from_device: this._baseApis.deviceId, + key_agreement_protocols: ["curve25519"], + hashes: ["sha256"], + message_authentication_codes: ["hmac-sha256"], + short_authentication_string: ["hex"], + transaction_id: this.transactionId, + }; + this._sendToDevice("m.key.verification.start", initialMessage); + + + let e = await this._waitForEvent("m.key.verification.accept"); + let content = e.getContent(); + if (!(content.key_agreement_protocol === "curve25519" + && content.hash === "sha256" + && content.message_authentication_code === "hmac-sha256" + && content.short_authentication_string instanceof Array + && content.short_authentication_string.length === 1 + && content.short_authentication_string[0] === "hex")) { + throw newUnknownMethodError(); + } + if (typeof content.commitment !== "string") { + throw newInvalidMessageError(); + } + const hashCommitment = content.commitment; + const olmSAS = new global.Olm.SAS(); + try { + this._sendToDevice("m.key.verification.key", { + key: olmSAS.get_pubkey(), + }); + + + e = await this._waitForEvent("m.key.verification.key"); + // FIXME: make sure event is properly formed + content = e.getContent(); + const commitmentStr = content.key + anotherjson.stringify(initialMessage); + if (olmutil.sha256(commitmentStr) !== hashCommitment) { + throw newMismatchedCommitmentError(); + } + olmSAS.set_their_key(content.key); + + const sasInfo = "MATRIX_KEY_VERIFICATION_SAS" + + this._baseApis.getUserId() + this._baseApis.deviceId + + this.userId + this.deviceId + + this.transactionId; + const sas = olmSAS.generate_bytes(sasInfo, 5).reduce((acc, elem) => { + return acc + ('0' + elem.toString(16)).slice(-2); + }, ""); + const verifySAS = new Promise((resolve, reject) => { + this.emit("show_sas", { + sas, + confirm: () => { + this._sendMAC(olmSAS); + resolve(); + }, + cancel: () => reject(newUserCancelledError()), + mismatch: () => reject(newMismatchedSASError()), + }); + }); + + + [e] = await Promise.all([ + this._waitForEvent("m.key.verification.mac"), + verifySAS, + ]); + content = e.getContent(); + await this._checkMAC(olmSAS, content); + } finally { + olmSAS.free(); + } + } + + async _doRespondVerification() { + let content = this.startEvent.getContent(); + if (!(content.key_agreement_protocols instanceof Array + && content.key_agreement_protocols.includes("curve25519") + && content.hashes instanceof Array + && content.hashes.includes("sha256") + && content.message_authentication_codes instanceof Array + && content.message_authentication_codes.includes("hmac-sha256") + && content.short_authentication_string instanceof Array + && content.short_authentication_string.includes("hex"))) { + throw newUnknownMethodError(); + } + + const olmSAS = new global.Olm.SAS(); + try { + const commitmentStr = olmSAS.get_pubkey() + anotherjson.stringify(content); + this._sendToDevice("m.key.verification.accept", { + key_agreement_protocol: "curve25519", + hash: "sha256", + message_authentication_code: "hmac-sha256", + short_authentication_string: ["hex"], + commitment: olmutil.sha256(commitmentStr), + }); + + + let e = await this._waitForEvent("m.key.verification.key"); + // FIXME: make sure event is properly formed + content = e.getContent(); + olmSAS.set_their_key(content.key); + this._sendToDevice("m.key.verification.key", { + key: olmSAS.get_pubkey(), + }); + + const sasInfo = "MATRIX_KEY_VERIFICATION_SAS" + + this.userId + this.deviceId + + this._baseApis.getUserId() + this._baseApis.deviceId + + this.transactionId; + const sas = olmSAS.generate_bytes(sasInfo, 5).reduce((acc, elem) => { + return acc + ('0' + elem.toString(16)).slice(-2); + }, ""); + const verifySAS = new Promise((resolve, reject) => { + this.emit("show_sas", { + sas, + confirm: () => { + this._sendMAC(olmSAS); + resolve(); + }, + cancel: () => reject(newUserCancelledError()), + mismatch: () => reject(newMismatchedSASError()), + }); + }); + + + [e] = await Promise.all([ + this._waitForEvent("m.key.verification.mac"), + verifySAS, + ]); + content = e.getContent(); + await this._checkMAC(olmSAS, content); + } finally { + olmSAS.free(); + } + } + + _sendMAC(olmSAS) { + const keyId = `ed25519:${this._baseApis.deviceId}`; + const mac = {}; + const baseInfo = "MATRIX_KEY_VERIFICATION_MAC" + + this._baseApis.getUserId() + this._baseApis.deviceId + + this.userId + this.deviceId + + this.transactionId; + + mac[keyId] = olmSAS.calculate_mac( + this._baseApis.getDeviceEd25519Key(), + baseInfo + keyId, + ); + const keys = olmSAS.calculate_mac( + keyId, + baseInfo + "KEY_IDS", + ); + this._sendToDevice("m.key.verification.mac", { mac, keys }); + } + + async _checkMAC(olmSAS, content) { + const baseInfo = "MATRIX_KEY_VERIFICATION_MAC" + + this.userId + this.deviceId + + this._baseApis.getUserId() + this._baseApis.deviceId + + this.transactionId; + + if (content.keys !== olmSAS.calculate_mac( + Object.keys(content.mac).sort().join(","), + baseInfo + "KEY_IDS", + )) { + throw newKeyMismatchError(); + } + + await this._verifyKeys(this.userId, content.mac, (keyId, device, keyInfo) => { + if (keyInfo !== olmSAS.calculate_mac( + device.keys[keyId], + baseInfo + keyId, + )) { + throw newKeyMismatchError(); + } + }); + } +} + +SAS.NAME = "m.sas.v1"; diff --git a/travis.sh b/travis.sh index ddf4a790e..02b2f0bbb 100755 --- a/travis.sh +++ b/travis.sh @@ -5,7 +5,7 @@ set -ex npm run lint # install Olm so that we can run the crypto tests. -npm install https://matrix.org/packages/npm/olm/olm-3.0.0.tgz +npm install https://matrix.org/packages/npm/olm/olm-3.1.0-pre1.tgz npm run test