diff --git a/spec/unit/crypto/verification/request.spec.js b/spec/unit/crypto/verification/request.spec.js index be039824c00..437db0efde3 100644 --- a/spec/unit/crypto/verification/request.spec.js +++ b/spec/unit/crypto/verification/request.spec.js @@ -22,6 +22,8 @@ import {makeTestClients} from './util'; const Olm = global.Olm; +jest.useFakeTimers(); + describe("verification request", function() { if (!global.Olm) { logger.warn('Not running device verification unit tests: libolm not present'); @@ -64,7 +66,9 @@ describe("verification request", function() { // XXX: Private function access (but it's a test, so we're okay) bobVerifier._endTimer(); }); - const aliceVerifier = await alice.client.requestVerification("@bob:example.com"); + const aliceRequest = await alice.client.requestVerification("@bob:example.com"); + await aliceRequest.waitFor(r => r.started); + const aliceVerifier = aliceRequest.verifier; expect(aliceVerifier).toBeInstanceOf(SAS); // XXX: Private function access (but it's a test, so we're okay) diff --git a/spec/unit/crypto/verification/sas.spec.js b/spec/unit/crypto/verification/sas.spec.js index 388737dd3c4..7d0ced92b6f 100644 --- a/spec/unit/crypto/verification/sas.spec.js +++ b/spec/unit/crypto/verification/sas.spec.js @@ -442,9 +442,11 @@ describe("SAS verification", function() { }); }); - aliceVerifier = await alice.client.requestVerificationDM( + const aliceRequest = await alice.client.requestVerificationDM( bob.client.getUserId(), "!room_id", [verificationMethods.SAS], ); + await aliceRequest.waitFor(r => r.started); + aliceVerifier = aliceRequest.verifier; aliceVerifier.on("show_sas", (e) => { if (!e.sas.emoji || !e.sas.decimal) { e.cancel(); diff --git a/spec/unit/crypto/verification/util.js b/spec/unit/crypto/verification/util.js index ea1cd81c925..01749077e71 100644 --- a/spec/unit/crypto/verification/util.js +++ b/spec/unit/crypto/verification/util.js @@ -33,15 +33,13 @@ export async function makeTestClients(userInfos, options) { content: msg, }); const client = clientMap[userId][deviceId]; - if (event.isEncrypted()) { - event.attemptDecryption(client._crypto) - .then(() => client.emit("toDeviceEvent", event)); - } else { - setTimeout( - () => client.emit("toDeviceEvent", event), - 0, - ); - } + const decryptionPromise = event.isEncrypted() ? + event.attemptDecryption(client._crypto) : + Promise.resolve(); + + decryptionPromise.then( + () => client.emit("toDeviceEvent", event), + ); } } } @@ -50,21 +48,33 @@ export async function makeTestClients(userInfos, options) { const sendEvent = function(room, type, content) { // make up a unique ID as the event ID const eventId = "$" + this.makeTxnId(); // eslint-disable-line babel/no-invalid-this - const event = new MatrixEvent({ + const rawEvent = { sender: this.getUserId(), // eslint-disable-line babel/no-invalid-this type: type, content: content, room_id: room, event_id: eventId, + origin_server_ts: Date.now(), + }; + const event = new MatrixEvent(rawEvent); + const remoteEcho = new MatrixEvent(Object.assign({}, rawEvent, { + unsigned: { + transaction_id: this.makeTxnId(), // eslint-disable-line babel/no-invalid-this + }, + })); + + setImmediate(() => { + for (const tc of clients) { + if (tc.client === this) { // eslint-disable-line babel/no-invalid-this + console.log("sending remote echo!!"); + tc.client.emit("Room.timeline", remoteEcho); + } else { + tc.client.emit("Room.timeline", event); + } + } }); - for (const tc of clients) { - setTimeout( - () => tc.client.emit("Room.timeline", event), - 0, - ); - } - return {event_id: eventId}; + return Promise.resolve({event_id: eventId}); }; for (const userInfo of userInfos) { diff --git a/src/client.js b/src/client.js index 8df4157fc3a..7b2f663f30a 100644 --- a/src/client.js +++ b/src/client.js @@ -853,8 +853,8 @@ async function _setDeviceVerification( * @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 + * @returns {Promise} resolves to a VerificationRequest + * when the request has been sent to the other party. */ MatrixClient.prototype.requestVerificationDM = function(userId, roomId, methods) { if (this._crypto === null) { @@ -863,22 +863,6 @@ MatrixClient.prototype.requestVerificationDM = function(userId, roomId, methods) return this._crypto.requestVerificationDM(userId, roomId, methods); }; -/** - * Accept a key verification request from a DM. - * - * @param {module:models/event~MatrixEvent} event the verification request - * that is accepted - * @param {string} method the verification mmethod to use - * - * @returns {module:crypto/verification/Base} a verifier - */ -MatrixClient.prototype.acceptVerificationDM = function(event, method) { - if (this._crypto === null) { - throw new Error("End-to-end encryption disabled"); - } - return this._crypto.acceptVerificationDM(event, method); -}; - /** * Request a key verification from another user. * @@ -888,8 +872,8 @@ MatrixClient.prototype.acceptVerificationDM = function(event, method) { * @param {Array} devices array of device IDs to send requests to. Defaults to * all devices owned by the user * - * @returns {Promise} resolves to a verifier - * when the request is accepted by the other user + * @returns {Promise} resolves to a VerificationRequest + * when the request has been sent to the other party. */ MatrixClient.prototype.requestVerification = function(userId, methods, devices) { if (this._crypto === null) { diff --git a/src/crypto/index.js b/src/crypto/index.js index bab0a2a2fb9..1c947360f48 100644 --- a/src/crypto/index.js +++ b/src/crypto/index.js @@ -46,8 +46,8 @@ import {SAS} from './verification/SAS'; import {keyFromPassphrase} from './key_passphrase'; import {encodeRecoveryKey} from './recoverykey'; import {VerificationRequest} from "./verification/request/VerificationRequest"; -import {InRoomChannel} from "./verification/request/InRoomChannel"; -import {ToDeviceChannel} from "./verification/request/ToDeviceChannel"; +import {InRoomChannel, InRoomRequests} from "./verification/request/InRoomChannel"; +import {ToDeviceChannel, ToDeviceRequests} from "./verification/request/ToDeviceChannel"; import * as httpApi from "../http-api"; const DeviceVerification = DeviceInfo.DeviceVerification; @@ -204,8 +204,8 @@ export function Crypto(baseApis, sessionStore, userId, deviceId, // } this._lastNewSessionForced = {}; - this._toDeviceVerificationRequests = new Map(); - this._inRoomVerificationRequests = new Map(); + this._toDeviceVerificationRequests = new ToDeviceRequests(); + this._inRoomVerificationRequests = new InRoomRequests(); const cryptoCallbacks = this._baseApis._cryptoCallbacks || {}; @@ -1148,17 +1148,13 @@ Crypto.prototype.registerEventHandlers = function(eventEmitter) { } }); - eventEmitter.on("toDeviceEvent", function(event) { - crypto._onToDeviceEvent(event); - }); + eventEmitter.on("toDeviceEvent", crypto._onToDeviceEvent.bind(crypto)); - eventEmitter.on("Room.timeline", function(event) { - crypto._onTimelineEvent(event); - }); + const timelineHandler = crypto._onTimelineEvent.bind(crypto); - eventEmitter.on("Event.decrypted", function(event) { - crypto._onTimelineEvent(event); - }); + eventEmitter.on("Room.timeline", timelineHandler); + + eventEmitter.on("Event.decrypted", timelineHandler); }; @@ -1558,98 +1554,79 @@ Crypto.prototype.setDeviceVerification = async function( return deviceObj; }; -Crypto.prototype.requestVerificationDM = async function(userId, roomId, methods) { +Crypto.prototype.requestVerificationDM = function(userId, roomId, methods) { const channel = new InRoomChannel(this._baseApis, roomId, userId); - const request = await this._requestVerificationWithChannel( + return this._requestVerificationWithChannel( userId, methods, channel, this._inRoomVerificationRequests, ); - return await request.waitForVerifier(); -}; - -Crypto.prototype.acceptVerificationDM = function(event, method) { - if(!InRoomChannel.validateEvent(event, this._baseApis)) { - return; - } - - const sender = event.getSender(); - const requestsByTxnId = this._inRoomVerificationRequests.get(sender); - if (!requestsByTxnId) { - return; - } - const transactionId = InRoomChannel.getTransactionId(event); - const request = requestsByTxnId.get(transactionId); - if (!request) { - return; - } - - return request.beginKeyVerification(method); }; -Crypto.prototype.requestVerification = async function(userId, methods, devices) { +Crypto.prototype.requestVerification = function(userId, methods, devices) { if (!devices) { devices = Object.keys(this._deviceList.getRawStoredDevicesForUser(userId)); } const channel = new ToDeviceChannel(this._baseApis, userId, devices); - const request = await this._requestVerificationWithChannel( + return this._requestVerificationWithChannel( userId, methods, channel, this._toDeviceVerificationRequests, ); - return await request.waitForVerifier(); }; Crypto.prototype._requestVerificationWithChannel = async function( userId, methods, channel, requestsMap, ) { - if (!methods) { - // .keys() returns an iterator, so we need to explicitly turn it into an array - methods = [...this._verificationMethods.keys()]; + let verificationMethods = this._verificationMethods; + if (methods) { + verificationMethods = methods.reduce((map, name) => { + const method = this._verificationMethods.get(name); + if (!method) { + throw new Error(`Verification method ${name} is not supported.`); + } else { + map.set(name, method); + } + return map; + }, new Map()); } - // TODO: filter by given methods - const request = new VerificationRequest( - channel, this._verificationMethods, userId, this._baseApis); + let request = new VerificationRequest( + channel, verificationMethods, this._baseApis); await request.sendRequest(); - - let requestsByTxnId = requestsMap.get(userId); - if (!requestsByTxnId) { - requestsByTxnId = new Map(); - requestsMap.set(userId, requestsByTxnId); + // don't replace the request created by a racing remote echo + const racingRequest = requestsMap.getRequestByChannel(channel); + if (racingRequest) { + request = racingRequest; + } else { + logger.log(`Crypto: adding new request to ` + + `requestsByTxnId with id ${channel.transactionId} ${channel.roomId}`); + requestsMap.setRequestByChannel(channel, request); } - // TODO: we're only adding the request to the map once it has been sent - // but if the other party is really fast they could potentially respond to the - // request before the server tells us the event got sent, and we would probably - // create a new request object - requestsByTxnId.set(channel.transactionId, request); - return request; }; Crypto.prototype.beginKeyVerification = function( method, userId, deviceId, transactionId = null, ) { - let requestsByTxnId = this._toDeviceVerificationRequests.get(userId); - if (!requestsByTxnId) { - requestsByTxnId = new Map(); - this._toDeviceVerificationRequests.set(userId, requestsByTxnId); - } let request; if (transactionId) { - request = requestsByTxnId.get(transactionId); + request = this._toDeviceVerificationRequests.getRequestBySenderAndTxnId( + userId, transactionId); + if (!request) { + throw new Error( + `No request found for user ${userId} with ` + + `transactionId ${transactionId}`); + } } else { transactionId = ToDeviceChannel.makeTransactionId(); const channel = new ToDeviceChannel( this._baseApis, userId, [deviceId], transactionId, deviceId); request = new VerificationRequest( - channel, this._verificationMethods, userId, this._baseApis); - requestsByTxnId.set(transactionId, request); - } - if (!request) { - throw new Error( - `No request found for user ${userId} with transactionId ${transactionId}`); + channel, this._verificationMethods, this._baseApis); + this._toDeviceVerificationRequests.setRequestBySenderAndTxnId( + userId, transactionId, request); } return request.beginKeyVerification(method, {userId, deviceId}); }; @@ -2535,7 +2512,6 @@ Crypto.prototype._onKeyVerificationMessage = function(event) { if (!ToDeviceChannel.validateEvent(event, this._baseApis)) { return; } - const transactionId = ToDeviceChannel.getTransactionId(event); const createRequest = event => { if (!ToDeviceChannel.canCreateRequest(ToDeviceChannel.getEventType(event))) { return; @@ -2552,10 +2528,13 @@ Crypto.prototype._onKeyVerificationMessage = function(event) { [deviceId], ); return new VerificationRequest( - channel, this._verificationMethods, userId, this._baseApis); + channel, this._verificationMethods, this._baseApis); }; - this._handleVerificationEvent(event, transactionId, - this._toDeviceVerificationRequests, createRequest); + this._handleVerificationEvent( + event, + this._toDeviceVerificationRequests, + createRequest, + ); }; /** @@ -2563,65 +2542,65 @@ Crypto.prototype._onKeyVerificationMessage = function(event) { * * @private * @param {module:models/event.MatrixEvent} event the timeline event + * @param {module:models/Room} room not used + * @param {bool} atStart not used + * @param {bool} removed not used + * @param {bool} data.liveEvent whether this is a live event */ -Crypto.prototype._onTimelineEvent = function(event) { +Crypto.prototype._onTimelineEvent = function( + event, room, atStart, removed, {liveEvent} = {}, +) { if (!InRoomChannel.validateEvent(event, this._baseApis)) { return; } - const transactionId = InRoomChannel.getTransactionId(event); const createRequest = event => { - if (!InRoomChannel.canCreateRequest(InRoomChannel.getEventType(event))) { - return; - } - const userId = event.getSender(); const channel = new InRoomChannel( this._baseApis, event.getRoomId(), - userId, ); return new VerificationRequest( - channel, this._verificationMethods, userId, this._baseApis); + channel, this._verificationMethods, this._baseApis); }; - this._handleVerificationEvent(event, transactionId, - this._inRoomVerificationRequests, createRequest); + this._handleVerificationEvent( + event, + this._inRoomVerificationRequests, + createRequest, + liveEvent, + ); }; Crypto.prototype._handleVerificationEvent = async function( - event, transactionId, requestsMap, createRequest, + event, requestsMap, createRequest, isLiveEvent = true, ) { - const sender = event.getSender(); - let requestsByTxnId = requestsMap.get(sender); + let request = requestsMap.getRequest(event); let isNewRequest = false; - let request = requestsByTxnId && requestsByTxnId.get(transactionId); if (!request) { request = createRequest(event); // a request could not be made from this event, so ignore event if (!request) { + logger.log(`Crypto: could not find VerificationRequest for ` + + `${event.getType()}, and could not create one, so ignoring.`); return; } isNewRequest = true; - if (!requestsByTxnId) { - requestsByTxnId = new Map(); - requestsMap.set(sender, requestsByTxnId); - } - requestsByTxnId.set(transactionId, request); + requestsMap.setRequest(event, request); } + event.setVerificationRequest(request); try { const hadVerifier = !!request.verifier; - await request.channel.handleEvent(event, request); + await request.channel.handleEvent(event, request, isLiveEvent); // emit start event when verifier got set if (!hadVerifier && request.verifier) { this._baseApis.emit("crypto.verification.start", request.verifier); } } catch (err) { - console.error("error while handling verification event", event, err); + logger.error("error while handling verification event: " + err.message); } - if (!request.pending) { - requestsByTxnId.delete(transactionId); - if (requestsByTxnId.size === 0) { - requestsMap.delete(sender); - } - } else if (isNewRequest && !request.initiatedByMe) { + const shouldEmit = isNewRequest && + !request.initiatedByMe && + !request.invalid && // check it has enough events to pass the UNSENT stage + !request.observeOnly; + if (shouldEmit) { this._baseApis.emit("crypto.verification.request", request); } }; diff --git a/src/crypto/verification/Base.js b/src/crypto/verification/Base.js index 82505f7a3c8..1bcba5ad6c1 100644 --- a/src/crypto/verification/Base.js +++ b/src/crypto/verification/Base.js @@ -67,9 +67,6 @@ export class VerificationBase extends EventEmitter { this._done = false; this._promise = null; this._transactionTimeoutTimer = null; - - // At this point, the verification request was received so start the timeout timer. - this._resetTimer(); } _resetTimer() { @@ -122,8 +119,15 @@ export class VerificationBase extends EventEmitter { } else if (e.getType() === "m.key.verification.cancel") { const reject = this._reject; this._reject = undefined; - reject(new Error("Other side cancelled verification")); - } else { + const content = e.getContent(); + const {reason, code} = content; + reject(new Error(`Other side cancelled verification ` + + `because ${reason} (${code})`)); + } else if (this._expectedEvent) { + // only cancel if there is an event expected. + // if there is no event expected, it means verify() wasn't called + // and we're just replaying the timeline events when syncing + // after a refresh when the events haven't been stored in the cache yet. const exception = new Error( "Unexpected message: expecting " + this._expectedEvent + " but got " + e.getType(), diff --git a/src/crypto/verification/Error.js b/src/crypto/verification/Error.js index 3ce1a49d7b5..5fe9fde4c93 100644 --- a/src/crypto/verification/Error.js +++ b/src/crypto/verification/Error.js @@ -23,12 +23,10 @@ limitations under the License. import {MatrixEvent} from "../../models/event"; export function newVerificationError(code, reason, extradata) { - extradata = extradata || {}; - extradata.code = code; - extradata.reason = reason; + const content = Object.assign({}, {code, reason}, extradata); return new MatrixEvent({ type: "m.key.verification.cancel", - content: extradata, + content, }); } diff --git a/src/crypto/verification/request/InRoomChannel.js b/src/crypto/verification/request/InRoomChannel.js index d89bb374192..ccd2b675876 100644 --- a/src/crypto/verification/request/InRoomChannel.js +++ b/src/crypto/verification/request/InRoomChannel.js @@ -15,7 +15,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {REQUEST_TYPE, START_TYPE, VerificationRequest} from "./VerificationRequest"; +import { + VerificationRequest, + REQUEST_TYPE, + READY_TYPE, + START_TYPE, +} from "./VerificationRequest"; +import {logger} from '../../../logger'; const MESSAGE_TYPE = "m.room.message"; const M_REFERENCE = "m.reference"; @@ -31,10 +37,10 @@ export class InRoomChannel { * @param {string} roomId id of the room where verification events should be posted in, should be a DM with the given user. * @param {string} userId id of user that the verification request is directed at, should be present in the room. */ - constructor(client, roomId, userId) { + constructor(client, roomId, userId = null) { this._client = client; this._roomId = roomId; - this._userId = userId; + this.userId = userId; this._requestEventId = null; } @@ -43,16 +49,45 @@ export class InRoomChannel { return true; } + get receiveStartFromOtherDevices() { + return true; + } + + get roomId() { + return this._roomId; + } + /** The transaction id generated/used by this verification channel */ get transactionId() { return this._requestEventId; } + static getOtherPartyUserId(event, client) { + const type = InRoomChannel.getEventType(event); + if (type !== REQUEST_TYPE) { + return; + } + const ownUserId = client.getUserId(); + const sender = event.getSender(); + const content = event.getContent(); + const receiver = content.to; + + // request is not sent by or directed at us + if (sender !== ownUserId && receiver !== ownUserId) { + return sender; + } + if (sender === ownUserId) { + return receiver; + } else { + return sender; + } + } + /** * @param {MatrixEvent} event the event to get the timestamp of * @return {number} the timestamp when the event was sent */ - static getTimestamp(event) { + getTimestamp(event) { return event.getTs(); } @@ -93,23 +128,28 @@ export class InRoomChannel { static validateEvent(event, client) { const txnId = InRoomChannel.getTransactionId(event); if (typeof txnId !== "string" || txnId.length === 0) { + logger.log("InRoomChannel: validateEvent: no valid txnId " + txnId); return false; } const type = InRoomChannel.getEventType(event); const content = event.getContent(); if (type === REQUEST_TYPE) { - if (typeof content.to !== "string" || !content.to.length) { + if (!content || typeof content.to !== "string" || !content.to.length) { + logger.log("InRoomChannel: validateEvent: " + + "no valid to " + (content && content.to)); return false; } - const ownUserId = client.getUserId(); + // ignore requests that are not direct to or sent by the syncing user - if (event.getSender() !== ownUserId && content.to !== ownUserId) { + if (!InRoomChannel.getOtherPartyUserId(event, client)) { + logger.log("InRoomChannel: validateEvent: " + + `not directed to or sent by me: ${event.getSender()}` + + `, ${content && content.to}`); return false; } } - return VerificationRequest.validateEvent( - type, event, InRoomChannel.getTimestamp(event), client); + return VerificationRequest.validateEvent(type, event, client); } /** @@ -137,21 +177,41 @@ export class InRoomChannel { * Changes the state of the channel, request, and verifier in response to a key verification event. * @param {MatrixEvent} event to handle * @param {VerificationRequest} request the request to forward handling to + * @param {bool} isLiveEvent whether this is an even received through sync or not * @returns {Promise} a promise that resolves when any requests as an anwser to the passed-in event are sent. */ - async handleEvent(event, request) { + async handleEvent(event, request, isLiveEvent) { const type = InRoomChannel.getEventType(event); // do validations that need state (roomId, userId), // ignore if invalid - if (event.getRoomId() !== this._roomId || event.getSender() !== this._userId) { + + if (event.getRoomId() !== this._roomId) { return; } - // set transactionId when receiving a .request - if (!this._requestEventId && type === REQUEST_TYPE) { - this._requestEventId = event.getId(); + // set userId if not set already + if (this.userId === null) { + const userId = InRoomChannel.getOtherPartyUserId(event, this._client); + if (userId) { + this.userId = userId; + } + } + // ignore events not sent by us or the other party + const ownUserId = this._client.getUserId(); + const sender = event.getSender(); + if (this.userId !== null) { + if (sender !== ownUserId && sender !== this.userId) { + logger.log(`InRoomChannel: ignoring verification event from ` + + `non-participating sender ${sender}`); + return; + } + } + if (this._requestEventId === null) { + this._requestEventId = InRoomChannel.getTransactionId(event); } - return await request.handleEvent(type, event, InRoomChannel.getTimestamp(event)); + const isRemoteEcho = !!event.getUnsigned().transaction_id; + + return await request.handleEvent(type, event, isLiveEvent, isRemoteEcho); } /** @@ -180,7 +240,7 @@ export class InRoomChannel { */ completeContent(type, content) { content = Object.assign({}, content); - if (type === REQUEST_TYPE || type === START_TYPE) { + if (type === REQUEST_TYPE || type === READY_TYPE || type === START_TYPE) { content.from_device = this._client.getDeviceId(); } if (type === REQUEST_TYPE) { @@ -191,7 +251,7 @@ export class InRoomChannel { "verification. You will need to use legacy key " + "verification to verify keys.", msgtype: REQUEST_TYPE, - to: this._userId, + to: this.userId, from_device: content.from_device, methods: content.methods, }; @@ -232,3 +292,59 @@ export class InRoomChannel { } } } + +export class InRoomRequests { + constructor() { + this._requestsByRoomId = new Map(); + } + + getRequest(event) { + const roomId = event.getRoomId(); + const txnId = InRoomChannel.getTransactionId(event); +// console.log(`looking for request in room ${roomId} with txnId ${txnId} for an ${event.getType()} from ${event.getSender()}...`); + return this._getRequestByTxnId(roomId, txnId); + } + + getRequestByChannel(channel) { + return this._getRequestByTxnId(channel.roomId, channel.transactionId); + } + + _getRequestByTxnId(roomId, txnId) { + const requestsByTxnId = this._requestsByRoomId.get(roomId); + if (requestsByTxnId) { + return requestsByTxnId.get(txnId); + } + } + + setRequest(event, request) { + this._setRequest( + event.getRoomId(), + InRoomChannel.getTransactionId(event), + request, + ); + } + + setRequestByChannel(channel, request) { + this._setRequest(channel.roomId, channel.transactionId, request); + } + + _setRequest(roomId, txnId, request) { + let requestsByTxnId = this._requestsByRoomId.get(roomId); + if (!requestsByTxnId) { + requestsByTxnId = new Map(); + this._requestsByRoomId.set(roomId, requestsByTxnId); + } + requestsByTxnId.set(txnId, request); + } + + removeRequest(event) { + const roomId = event.getRoomId(); + const requestsByTxnId = this._requestsByRoomId.get(roomId); + if (requestsByTxnId) { + requestsByTxnId.delete(InRoomChannel.getTransactionId(event)); + if (requestsByTxnId.size === 0) { + this._requestsByRoomId.delete(roomId); + } + } + } +} diff --git a/src/crypto/verification/request/RequestCallbackChannel.js b/src/crypto/verification/request/RequestCallbackChannel.js deleted file mode 100644 index 7b1b125042d..00000000000 --- a/src/crypto/verification/request/RequestCallbackChannel.js +++ /dev/null @@ -1,59 +0,0 @@ -/* -Copyright 2019 The Matrix.org Foundation C.I.C. - -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. -*/ - -/** a key verification channel that wraps over an actual channel to pass it to a verifier, - * to notify the VerificationRequest when the verifier tries to send anything over the channel. - * This way, the VerificationRequest can update its state based on events sent by the verifier. - * Anything that is not sending is just routing through to the wrapped channel. - */ -export class RequestCallbackChannel { - constructor(request, channel) { - this._request = request; - this._channel = channel; - } - - get transactionId() { - return this._channel.transactionId; - } - - get needsDoneMessage() { - return this._channel.needsDoneMessage; - } - - handleEvent(event, request) { - return this._channel.handleEvent(event, request); - } - - completedContentFromEvent(event) { - return this._channel.completedContentFromEvent(event); - } - - completeContent(type, content) { - return this._channel.completeContent(type, content); - } - - async send(type, uncompletedContent) { - this._request.handleVerifierSend(type, uncompletedContent); - const result = await this._channel.send(type, uncompletedContent); - return result; - } - - async sendCompleted(type, content) { - this._request.handleVerifierSend(type, content); - const result = await this._channel.sendCompleted(type, content); - return result; - } -} diff --git a/src/crypto/verification/request/ToDeviceChannel.js b/src/crypto/verification/request/ToDeviceChannel.js index dc9d74e3164..9f4995cac2e 100644 --- a/src/crypto/verification/request/ToDeviceChannel.js +++ b/src/crypto/verification/request/ToDeviceChannel.js @@ -20,11 +20,14 @@ import {logger} from '../../../logger'; import { CANCEL_TYPE, PHASE_STARTED, + PHASE_READY, REQUEST_TYPE, + READY_TYPE, START_TYPE, VerificationRequest, } from "./VerificationRequest"; import {errorFromEvent, newUnexpectedMessageError} from "../Error"; +import {MatrixEvent} from "../../../models/event"; /** * A key verification channel that sends verification events over to_device messages. @@ -34,7 +37,7 @@ export class ToDeviceChannel { // userId and devices of user we're about to verify constructor(client, userId, devices, transactionId = null, deviceId = null) { this._client = client; - this._userId = userId; + this.userId = userId; this._devices = devices; this.transactionId = transactionId; this._deviceId = deviceId; @@ -80,10 +83,12 @@ export class ToDeviceChannel { } const content = event.getContent(); if (!content) { + logger.warn("ToDeviceChannel.validateEvent: invalid: no content"); return false; } if (!content.transaction_id) { + logger.warn("ToDeviceChannel.validateEvent: invalid: no transaction_id"); return false; } @@ -91,6 +96,7 @@ export class ToDeviceChannel { if (type === REQUEST_TYPE) { if (!Number.isFinite(content.timestamp)) { + logger.warn("ToDeviceChannel.validateEvent: invalid: no timestamp"); return false; } if (event.getSender() === client.getUserId() && @@ -98,19 +104,19 @@ export class ToDeviceChannel { ) { // ignore requests from ourselves, because it doesn't make sense for a // device to verify itself + logger.warn("ToDeviceChannel.validateEvent: invalid: from own device"); return false; } } - return VerificationRequest.validateEvent( - type, event, ToDeviceChannel.getTimestamp(event), client); + return VerificationRequest.validateEvent(type, event, client); } /** * @param {MatrixEvent} event the event to get the timestamp of * @return {number} the timestamp when the event was sent */ - static getTimestamp(event) { + getTimestamp(event) { const content = event.getContent(); return content && content.timestamp; } @@ -119,9 +125,10 @@ export class ToDeviceChannel { * Changes the state of the channel, request, and verifier in response to a key verification event. * @param {MatrixEvent} event to handle * @param {VerificationRequest} request the request to forward handling to + * @param {bool} isLiveEvent whether this is an even received through sync or not * @returns {Promise} a promise that resolves when any requests as an anwser to the passed-in event are sent. */ - async handleEvent(event, request) { + async handleEvent(event, request, isLiveEvent) { const type = event.getType(); const content = event.getContent(); if (type === REQUEST_TYPE || type === START_TYPE) { @@ -143,14 +150,17 @@ export class ToDeviceChannel { return this._sendToDevices(CANCEL_TYPE, cancelContent, [deviceId]); } } + const wasStarted = request.phase === PHASE_STARTED || + request.phase === PHASE_READY; - const wasStarted = request.phase === PHASE_STARTED; - await request.handleEvent( - event.getType(), event, ToDeviceChannel.getTimestamp(event)); - const isStarted = request.phase === PHASE_STARTED; + await request.handleEvent(event.getType(), event, isLiveEvent, false); - // the request has picked a start event, tell the other devices about it - if (type === START_TYPE && !wasStarted && isStarted && this._deviceId) { + const isStarted = request.phase === PHASE_STARTED || + request.phase === PHASE_READY; + + const isAcceptingEvent = type === START_TYPE || type === READY_TYPE; + // the request has picked a ready or start event, tell the other devices about it + if (isAcceptingEvent && !wasStarted && isStarted && this._deviceId) { const nonChosenDevices = this._devices.filter(d => d !== this._deviceId); if (nonChosenDevices.length) { const message = this.completeContent({ @@ -186,7 +196,7 @@ export class ToDeviceChannel { if (this.transactionId) { content.transaction_id = this.transactionId; } - if (type === REQUEST_TYPE || type === START_TYPE) { + if (type === REQUEST_TYPE || type === READY_TYPE || type === START_TYPE) { content.from_device = this._client.getDeviceId(); } if (type === REQUEST_TYPE) { @@ -216,12 +226,27 @@ export class ToDeviceChannel { * @param {object} content * @returns {Promise} the promise of the request */ - sendCompleted(type, content) { + async sendCompleted(type, content) { + let result; if (type === REQUEST_TYPE) { - return this._sendToDevices(type, content, this._devices); + result = await this._sendToDevices(type, content, this._devices); } else { - return this._sendToDevices(type, content, [this._deviceId]); + result = await this._sendToDevices(type, content, [this._deviceId]); } + // the VerificationRequest state machine requires remote echos of the event + // the client sends itself, so we fake this for to_device messages + const remoteEchoEvent = new MatrixEvent({ + sender: this._client.getUserId(), + content, + type, + }); + await this._request.handleEvent( + type, + remoteEchoEvent, + /*isLiveEvent=*/true, + /*isRemoteEcho=*/true, + ); + return result; } _sendToDevices(type, content, devices) { @@ -231,7 +256,7 @@ export class ToDeviceChannel { msgMap[deviceId] = content; } - return this._client.sendToDevice(type, {[this._userId]: msgMap}); + return this._client.sendToDevice(type, {[this.userId]: msgMap}); } else { return Promise.resolve(); } @@ -245,3 +270,60 @@ export class ToDeviceChannel { return randomString(32); } } + + +export class ToDeviceRequests { + constructor() { + this._requestsByUserId = new Map(); + } + + getRequest(event) { + return this.getRequestBySenderAndTxnId( + event.getSender(), + ToDeviceChannel.getTransactionId(event), + ); + } + + getRequestByChannel(channel) { + return this.getRequestBySenderAndTxnId(channel.userId, channel.transactionId); + } + + getRequestBySenderAndTxnId(sender, txnId) { + const requestsByTxnId = this._requestsByUserId.get(sender); + if (requestsByTxnId) { + return requestsByTxnId.get(txnId); + } + } + + setRequest(event, request) { + this.setRequestBySenderAndTxnId( + event.getSender(), + ToDeviceChannel.getTransactionId(event), + request, + ); + } + + setRequestByChannel(channel, request) { + this.setRequestBySenderAndTxnId(channel.userId, channel.transactionId, request); + } + + setRequestBySenderAndTxnId(sender, txnId, request) { + let requestsByTxnId = this._requestsByUserId.get(sender); + if (!requestsByTxnId) { + requestsByTxnId = new Map(); + this._requestsByUserId.set(sender, requestsByTxnId); + } + requestsByTxnId.set(txnId, request); + } + + removeRequest(event) { + const userId = event.getSender(); + const requestsByTxnId = this._requestsByUserId.get(userId); + if (requestsByTxnId) { + requestsByTxnId.delete(ToDeviceChannel.getTransactionId(event)); + if (requestsByTxnId.size === 0) { + this._requestsByUserId.delete(userId); + } + } + } +} diff --git a/src/crypto/verification/request/VerificationRequest.js b/src/crypto/verification/request/VerificationRequest.js index 77a54f998dd..d2139d10a54 100644 --- a/src/crypto/verification/request/VerificationRequest.js +++ b/src/crypto/verification/request/VerificationRequest.js @@ -16,7 +16,6 @@ limitations under the License. */ import {logger} from '../../../logger'; -import {RequestCallbackChannel} from "./RequestCallbackChannel"; import {EventEmitter} from 'events'; import { errorFactory, @@ -41,11 +40,11 @@ export const REQUEST_TYPE = EVENT_PREFIX + "request"; export const START_TYPE = EVENT_PREFIX + "start"; export const CANCEL_TYPE = EVENT_PREFIX + "cancel"; export const DONE_TYPE = EVENT_PREFIX + "done"; -// export const READY_TYPE = EVENT_PREFIX + "ready"; +export const READY_TYPE = EVENT_PREFIX + "ready"; export const PHASE_UNSENT = 1; export const PHASE_REQUESTED = 2; -// const PHASE_READY = 3; +export const PHASE_READY = 3; export const PHASE_STARTED = 4; export const PHASE_CANCELLED = 5; export const PHASE_DONE = 6; @@ -58,17 +57,18 @@ export const PHASE_DONE = 6; * @event "change" whenever the state of the request object has changed. */ export class VerificationRequest extends EventEmitter { - constructor(channel, verificationMethods, userId, client) { + constructor(channel, verificationMethods, client) { super(); this.channel = channel; + this.channel._request = this; this._verificationMethods = verificationMethods; this._client = client; this._commonMethods = []; this._setPhase(PHASE_UNSENT, false); - this._requestEvent = null; - this._otherUserId = userId; - this._initiatedByMe = null; - this._startTimestamp = null; + this._eventsByUs = new Map(); + this._eventsByThem = new Map(); + this._observeOnly = false; + this._timeoutTimer = null; } /** @@ -76,37 +76,36 @@ export class VerificationRequest extends EventEmitter { * Invoked by the same static method in either channel. * @param {string} type the "symbolic" event type, as returned by the `getEventType` function on the channel. * @param {MatrixEvent} event the event to validate. Don't call getType() on it but use the `type` parameter instead. - * @param {number} timestamp the timestamp in milliseconds when this event was sent. * @param {MatrixClient} client the client to get the current user and device id from * @returns {bool} whether the event is valid and should be passed to handleEvent */ - static validateEvent(type, event, timestamp, client) { + static validateEvent(type, event, client) { const content = event.getContent(); + if (!content) { + logger.log("VerificationRequest: validateEvent: no content"); + } + if (!type.startsWith(EVENT_PREFIX)) { + logger.log("VerificationRequest: validateEvent: " + + "fail because type doesnt start with " + EVENT_PREFIX); return false; } - if (type === REQUEST_TYPE) { + if (type === REQUEST_TYPE || type === READY_TYPE) { if (!Array.isArray(content.methods)) { + logger.log("VerificationRequest: validateEvent: " + + "fail because methods"); return false; } } - if (type === REQUEST_TYPE || type === START_TYPE) { + + if (type === REQUEST_TYPE || type === READY_TYPE || type === START_TYPE) { if (typeof content.from_device !== "string" || content.from_device.length === 0 ) { - return false; - } - } - - // a timestamp is not provided on all to_device events - if (Number.isFinite(timestamp)) { - const elapsed = Date.now() - timestamp; - // ignore if event is too far in the past or too far in the future - if (elapsed > (VERIFICATION_REQUEST_TIMEOUT - VERIFICATION_REQUEST_MARGIN) || - elapsed < -(VERIFICATION_REQUEST_TIMEOUT / 2)) { - logger.log("received verification that is too old or from the future"); + logger.log("VerificationRequest: validateEvent: "+ + "fail because from_device"); return false; } } @@ -114,20 +113,53 @@ export class VerificationRequest extends EventEmitter { return true; } - /** once the phase is PHASE_STARTED, common methods supported by both sides */ + get invalid() { + return this.phase === PHASE_UNSENT; + } + + /** returns whether the phase is PHASE_REQUESTED */ + get requested() { + return this.phase === PHASE_REQUESTED; + } + + /** returns whether the phase is PHASE_CANCELLED */ + get cancelled() { + return this.phase === PHASE_CANCELLED; + } + + /** returns whether the phase is PHASE_READY */ + get ready() { + return this.phase === PHASE_READY; + } + + /** returns whether the phase is PHASE_STARTED */ + get started() { + return this.phase === PHASE_STARTED; + } + + /** returns whether the phase is PHASE_DONE */ + get done() { + return this.phase === PHASE_DONE; + } + + /** once the phase is PHASE_STARTED (and !initiatedByMe) or PHASE_READY: common methods supported by both sides */ get methods() { return this._commonMethods; } - /** the timeout of the request, provided for compatibility with previous verification code */ + /** the current remaining amount of ms before the request should be automatically cancelled */ get timeout() { - const elapsed = Date.now() - this._startTimestamp; - return Math.max(0, VERIFICATION_REQUEST_TIMEOUT - elapsed); + const requestEvent = this._getEventByEither(REQUEST_TYPE); + if (requestEvent) { + const elapsed = Date.now() - this.channel.getTimestamp(requestEvent); + return Math.max(0, VERIFICATION_REQUEST_TIMEOUT - elapsed); + } + return 0; } /** the m.key.verification.request event that started this request, provided for compatibility with previous verification code */ get event() { - return this._requestEvent; + return this._getEventByEither(REQUEST_TYPE) || this._getEventByEither(START_TYPE); } /** current phase of the request. Some properties might only be defined in a current phase. */ @@ -142,8 +174,7 @@ export class VerificationRequest extends EventEmitter { /** whether this request has sent it's initial event and needs more events to complete */ get pending() { - return this._phase !== PHASE_UNSENT - && this._phase !== PHASE_DONE + return this._phase !== PHASE_DONE && this._phase !== PHASE_CANCELLED; } @@ -152,7 +183,25 @@ export class VerificationRequest extends EventEmitter { * For ToDeviceChannel, this is who sent the .start event */ get initiatedByMe() { - return this._initiatedByMe; + // event created by us but no remote echo has been received yet + const noEventsYet = (this._eventsByUs.size + this._eventsByThem.size) === 0; + if (this._phase === PHASE_UNSENT && noEventsYet) { + return true; + } + const hasMyRequest = this._eventsByUs.has(REQUEST_TYPE); + const hasTheirRequest = this._eventsByThem.has(REQUEST_TYPE); + if (hasMyRequest && !hasTheirRequest) { + return true; + } + if (!hasMyRequest && hasTheirRequest) { + return false; + } + const hasMyStart = this._eventsByUs.has(START_TYPE); + const hasTheirStart = this._eventsByThem.has(START_TYPE); + if (hasMyStart && !hasTheirStart) { + return true; + } + return false; } /** the id of the user that initiated the request */ @@ -160,19 +209,45 @@ export class VerificationRequest extends EventEmitter { if (this.initiatedByMe) { return this._client.getUserId(); } else { - return this._otherUserId; + return this.otherUserId; } } /** the id of the user that (will) receive(d) the request */ get receivingUserId() { if (this.initiatedByMe) { - return this._otherUserId; + return this.otherUserId; } else { return this._client.getUserId(); } } + /** the user id of the other party in this request */ + get otherUserId() { + return this.channel.userId; + } + + /** + * the id of the user that cancelled the request, + * only defined when phase is PHASE_CANCELLED + */ + get cancellingUserId() { + const myCancel = this._eventsByUs.get(CANCEL_TYPE); + const theirCancel = this._eventsByThem.get(CANCEL_TYPE); + + if (myCancel && (!theirCancel || myCancel.getId() < theirCancel.getId())) { + return myCancel.getSender(); + } + if (theirCancel) { + return theirCancel.getSender(); + } + return undefined; + } + + get observeOnly() { + return this._observeOnly; + } + /* Start the key verification, creating a verifier and sending a .start event. * If no previous events have been sent, pass in `targetDevice` to set who to direct this request to. * @param {string} method the name of the verification method to use. @@ -182,8 +257,13 @@ export class VerificationRequest extends EventEmitter { */ beginKeyVerification(method, targetDevice = null) { // need to allow also when unsent in case of to_device - if (!this._verifier) { - if (this._hasValidPreStartPhase()) { + if (!this.observeOnly && !this._verifier) { + const validStartPhase = + this.phase === PHASE_REQUESTED || + this.phase === PHASE_READY || + (this.phase === PHASE_UNSENT && + this.channel.constructor.canCreateRequest(START_TYPE)); + if (validStartPhase) { // when called on a request that was initiated with .request event // check the method is supported by both sides if (this._commonMethods.length && !this._commonMethods.includes(method)) { @@ -203,12 +283,9 @@ export class VerificationRequest extends EventEmitter { * @returns {Promise} resolves when the event has been sent. */ async sendRequest() { - if (this._phase === PHASE_UNSENT) { - this._initiatedByMe = true; - this._setPhase(PHASE_REQUESTED, false); + if (!this.observeOnly && this._phase === PHASE_UNSENT) { const methods = [...this._verificationMethods.keys()]; await this.channel.send(REQUEST_TYPE, {methods}); - this.emit("change"); } } @@ -219,34 +296,56 @@ export class VerificationRequest extends EventEmitter { * @returns {Promise} resolves when the event has been sent. */ async cancel({reason = "User declined", code = "m.user"} = {}) { - if (this._phase !== PHASE_CANCELLED) { + if (!this.observeOnly && this._phase !== PHASE_CANCELLED) { if (this._verifier) { return this._verifier.cancel(errorFactory(code, reason)); } else { - this._setPhase(PHASE_CANCELLED, false); + this._cancellingUserId = this._client.getUserId(); await this.channel.send(CANCEL_TYPE, {code, reason}); } - this.emit("change"); } } - /** @returns {Promise} with the verifier once it becomes available. Can be used after calling `sendRequest`. */ - waitForVerifier() { - if (this.verifier) { - return Promise.resolve(this.verifier); - } else { - return new Promise(resolve => { - const checkVerifier = () => { - if (this.verifier) { - this.off("change", checkVerifier); - resolve(this.verifier); - } - }; - this.on("change", checkVerifier); - }); + /** + * Accepts the request, sending a .ready event to the other party + * @returns {Promise} resolves when the event has been sent. + */ + async accept() { + if (!this.observeOnly && this.phase === PHASE_REQUESTED && !this.initiatedByMe) { + const methods = [...this._verificationMethods.keys()]; + await this.channel.send(READY_TYPE, {methods}); } } + /** + * Can be used to listen for state changes until the callback returns true. + * @param {Function} fn callback to evaluate whether the request is in the desired state. + * Takes the request as an argument. + * @returns {Promise} that resolves once the callback returns true + * @throws {Error} when the request is cancelled + */ + waitFor(fn) { + return new Promise((resolve, reject) => { + const check = () => { + let handled = false; + if (fn(this)) { + resolve(this); + handled = true; + } else if (this.cancelled) { + reject(new Error("cancelled")); + handled = true; + } + if (handled) { + this.off("change", check); + } + return handled; + }; + if (!check()) { + this.on("change", check); + } + }); + } + _setPhase(phase, notify = true) { this._phase = phase; if (notify) { @@ -254,155 +353,278 @@ export class VerificationRequest extends EventEmitter { } } + _getEventByEither(type) { + return this._eventsByThem.get(type) || this._eventsByUs.get(type); + } + + _getEventByOther(type, notSender) { + if (notSender === this._client.getUserId()) { + return this._eventsByThem.get(type); + } else { + return this._eventsByUs.get(type); + } + } + + _getEventBy(type, sender) { + if (sender === this._client.getUserId()) { + return this._eventsByUs.get(type); + } else { + return this._eventsByThem.get(type); + } + } + + _calculatePhaseTransitions() { + const transitions = [{phase: PHASE_UNSENT}]; + const phase = () => transitions[transitions.length - 1].phase; + + // always pass by .request first to be sure channel.userId has been set + const requestEvent = this._getEventByEither(REQUEST_TYPE); + if (requestEvent) { + transitions.push({phase: PHASE_REQUESTED, event: requestEvent}); + } + + const readyEvent = + requestEvent && this._getEventByOther(READY_TYPE, requestEvent.getSender()); + if (readyEvent && phase() === PHASE_REQUESTED) { + transitions.push({phase: PHASE_READY, event: readyEvent}); + } + + const startEvent = readyEvent || !requestEvent ? + this._getEventByEither(START_TYPE) : // any party can send .start after a .ready or unsent + this._getEventByOther(START_TYPE, requestEvent.getSender()); + if (startEvent) { + const fromRequestPhase = phase() === PHASE_REQUESTED && + requestEvent.getSender() !== startEvent.getSender(); + const fromUnsentPhase = phase() === PHASE_UNSENT && + this.channel.constructor.canCreateRequest(START_TYPE); + if (fromRequestPhase || phase() === PHASE_READY || fromUnsentPhase) { + transitions.push({phase: PHASE_STARTED, event: startEvent}); + } + } + + const ourDoneEvent = this._eventsByUs.get(DONE_TYPE); + const theirDoneEvent = this._eventsByThem.get(DONE_TYPE); + if (ourDoneEvent && theirDoneEvent && phase() === PHASE_STARTED) { + transitions.push({phase: PHASE_DONE}); + } + + const cancelEvent = this._getEventByEither(CANCEL_TYPE); + if (cancelEvent && phase() !== PHASE_DONE) { + transitions.push({phase: PHASE_CANCELLED, event: cancelEvent}); + return transitions; + } + + return transitions; + } + + _transitionToPhase(transition) { + const {phase, event} = transition; + // get common methods + if (phase === PHASE_REQUESTED || phase === PHASE_READY) { + if (!this._wasSentByOwnDevice(event)) { + const content = event.getContent(); + this._commonMethods = + content.methods.filter(m => this._verificationMethods.has(m)); + } + } + // detect if we're not a party in the request, and we should just observe + if (!this.observeOnly) { + // if requested or accepted by one of my other devices + if (phase === PHASE_REQUESTED || + phase === PHASE_STARTED || + phase === PHASE_READY + ) { + if ( + this.channel.receiveStartFromOtherDevices && + this._wasSentByOwnUser(event) && + !this._wasSentByOwnDevice(event) + ) { + this._observeOnly = true; + } + } + } + // create verifier + if (phase === PHASE_STARTED) { + const {method} = event.getContent(); + if (!this._verifier && !this.observeOnly) { + this._verifier = this._createVerifier(method, event); + } + } + } + /** * Changes the state of the request and verifier in response to a key verification event. * @param {string} type the "symbolic" event type, as returned by the `getEventType` function on the channel. * @param {MatrixEvent} event the event to handle. Don't call getType() on it but use the `type` parameter instead. - * @param {number} timestamp the timestamp in milliseconds when this event was sent. + * @param {bool} isLiveEvent whether this is an even received through sync or not + * @param {bool} isRemoteEcho whether this is the remote echo of an event sent by the same device * @returns {Promise} a promise that resolves when any requests as an anwser to the passed-in event are sent. */ - async handleEvent(type, event, timestamp) { - const content = event.getContent(); - if (type === REQUEST_TYPE || type === START_TYPE) { - if (this._startTimestamp === null) { - this._startTimestamp = timestamp; - } + async handleEvent(type, event, isLiveEvent, isRemoteEcho) { + // if reached phase cancelled or done, ignore anything else that comes + if (!this.pending) { + return; } - if (type === REQUEST_TYPE) { - await this._handleRequest(content, event); - } else if (type === START_TYPE) { - await this._handleStart(content, event); + + this._adjustObserveOnly(event, isLiveEvent); + + if (!this.observeOnly && !isRemoteEcho) { + if (await this._cancelOnError(type, event)) { + return; + } } - if (this._verifier) { + this._addEvent(type, event, isRemoteEcho); + + const transitions = this._calculatePhaseTransitions(); + const existingIdx = transitions.findIndex(t => t.phase === this.phase); + // trim off phases we already went through, if any + const newTransitions = transitions.slice(existingIdx + 1); + // transition to all new phases + for (const transition of newTransitions) { + this._transitionToPhase(transition); + } + // only pass events from the other side to the verifier, + // no remote echos of our own events + if (this._verifier && !isRemoteEcho) { if (type === CANCEL_TYPE || (this._verifier.events && this._verifier.events.includes(type))) { this._verifier.handleEvent(event); } } - if (type === CANCEL_TYPE) { - this._handleCancel(); - } else if (type === DONE_TYPE) { - this._handleDone(); + if (newTransitions.length) { + const lastTransition = newTransitions[newTransitions.length - 1]; + const {phase} = lastTransition; + + this._setupTimeout(phase); + // set phase as last thing as this emits the "change" event + this._setPhase(phase); } } - async _handleRequest(content, event) { - if (this._phase === PHASE_UNSENT) { - const otherMethods = content.methods; - this._commonMethods = otherMethods. - filter(m => this._verificationMethods.has(m)); - this._requestEvent = event; - this._initiatedByMe = this._wasSentByMe(event); - this._setPhase(PHASE_REQUESTED); - } else if (this._phase !== PHASE_REQUESTED) { - logger.warn("Ignoring flagged verification request from " + - event.getSender()); - await this.cancel(errorFromEvent(newUnexpectedMessageError())); + _setupTimeout(phase) { + const shouldTimeout = !this._timeoutTimer && !this.observeOnly && + phase === PHASE_REQUESTED && this.initiatedByMe; + + if (shouldTimeout) { + this._timeoutTimer = setTimeout(this._cancelOnTimeout, this.timeout); + } + if (this._timeoutTimer) { + const shouldClear = phase === PHASE_STARTED || + phase === PHASE_READY || + phase === PHASE_DONE || + phase === PHASE_CANCELLED; + if (shouldClear) { + clearTimeout(this._timeoutTimer); + this._timeoutTimer = null; + } } } - _hasValidPreStartPhase() { - return this._phase === PHASE_REQUESTED || - ( - this.channel.constructor.canCreateRequest(START_TYPE) && - this._phase === PHASE_UNSENT - ); - } + _cancelOnTimeout = () => { + try { + this.cancel({reason: "Other party didn't accept in time", code: "m.timeout"}); + } catch (err) { + console.error("Error while cancelling verification request", err); + } + }; - async _handleStart(content, event) { - if (this._hasValidPreStartPhase()) { - const {method} = content; + async _cancelOnError(type, event) { + if (type === START_TYPE) { + const method = event.getContent().method; if (!this._verificationMethods.has(method)) { await this.cancel(errorFromEvent(newUnknownMethodError())); - } else { - // if not in requested phase - if (this.phase === PHASE_UNSENT) { - this._initiatedByMe = this._wasSentByMe(event); - } - this._verifier = this._createVerifier(method, event); - this._setPhase(PHASE_STARTED); + return true; } } + + /* FIXME: https://github.com/vector-im/riot-web/issues/11765 */ + const isUnexpectedRequest = type === REQUEST_TYPE && this.phase !== PHASE_UNSENT; + const isUnexpectedReady = type === READY_TYPE && this.phase !== PHASE_REQUESTED; + if (isUnexpectedRequest || isUnexpectedReady) { + logger.warn(`Cancelling, unexpected ${type} verification ` + + `event from ${event.getSender()}`); + const reason = `Unexpected ${type} event in phase ${this.phase}`; + await this.cancel(errorFromEvent(newUnexpectedMessageError({reason}))); + return true; + } + return false; } - /** - * Called by RequestCallbackChannel when the verifier sends an event - * @param {string} type the "symbolic" event type - * @param {object} content the completed or uncompleted content for the event to be sent - */ - handleVerifierSend(type, content) { - if (type === CANCEL_TYPE) { - this._handleCancel(); - } else if (type === START_TYPE) { - if (this._phase === PHASE_UNSENT || this._phase === PHASE_REQUESTED) { - // if unsent, we're sending a (first) .start event and hence requesting the verification. - // in any other situation, the request was initiated by the other party. - this._initiatedByMe = this.phase === PHASE_UNSENT; - this._setPhase(PHASE_STARTED); + _adjustObserveOnly(event, isLiveEvent) { + // don't send out events for historical requests + if (!isLiveEvent) { + this._observeOnly = true; + } + // a timestamp is not provided on all to_device events + const timestamp = this.channel.getTimestamp(event); + if (Number.isFinite(timestamp)) { + const elapsed = Date.now() - timestamp; + // don't allow interaction on old requests + if (elapsed > (VERIFICATION_REQUEST_TIMEOUT - VERIFICATION_REQUEST_MARGIN) || + elapsed < -(VERIFICATION_REQUEST_TIMEOUT / 2) + ) { + this._observeOnly = true; } } } - _handleCancel() { - if (this._phase !== PHASE_CANCELLED) { - this._setPhase(PHASE_CANCELLED); + _addEvent(type, event, isRemoteEcho) { + if (isRemoteEcho || this._wasSentByOwnDevice(event)) { + this._eventsByUs.set(type, event); + } else { + this._eventsByThem.set(type, event); } - } - _handleDone() { - if (this._phase === PHASE_STARTED) { - this._setPhase(PHASE_DONE); + // once we know the userId of the other party (from the .request event) + // see if any event by anyone else crept into this._eventsByThem + if (type === REQUEST_TYPE) { + for (const [type, event] of this._eventsByThem.entries()) { + if (event.getSender() !== this.otherUserId) { + this._eventsByThem.delete(type); + } + } } } _createVerifier(method, startEvent = null, targetDevice = null) { - const startSentByMe = startEvent && this._wasSentByMe(startEvent); - const {userId, deviceId} = this._getVerifierTarget(startEvent, targetDevice); + const startedByMe = !startEvent || this._wasSentByOwnDevice(startEvent); + if (!targetDevice) { + const theirFirstEvent = + this._eventsByThem.get(REQUEST_TYPE) || + this._eventsByThem.get(READY_TYPE) || + this._eventsByThem.get(START_TYPE); + const theirFirstContent = theirFirstEvent.getContent(); + const fromDevice = theirFirstContent.from_device; + targetDevice = { + userId: this.otherUserId, + deviceId: fromDevice, + }; + } + const {userId, deviceId} = targetDevice; const VerifierCtor = this._verificationMethods.get(method); if (!VerifierCtor) { console.warn("could not find verifier constructor for method", method); return; } - // invokes handleVerifierSend when verifier sends something - const callbackMedium = new RequestCallbackChannel(this, this.channel); return new VerifierCtor( - callbackMedium, + this.channel, this._client, userId, deviceId, - startSentByMe ? null : startEvent, + startedByMe ? null : startEvent, ); } - _getVerifierTarget(startEvent, targetDevice) { - // targetDevice should be set when creating a verifier for to_device before the .start event has been sent, - // so the userId and deviceId are provided - if (targetDevice) { - return targetDevice; - } else { - let targetEvent; - if (startEvent && !this._wasSentByMe(startEvent)) { - targetEvent = startEvent; - } else if (this._requestEvent && !this._wasSentByMe(this._requestEvent)) { - targetEvent = this._requestEvent; - } else { - throw new Error( - "can't determine who the verifier should be targeted at. " + - "No .request or .start event and no targetDevice"); - } - const userId = targetEvent.getSender(); - const content = targetEvent.getContent(); - const deviceId = content && content.from_device; - return {userId, deviceId}; - } + _wasSentByOwnUser(event) { + return event.getSender() === this._client.getUserId(); } - // only for .request and .start - _wasSentByMe(event) { - if (event.getSender() !== this._client.getUserId()) { + // only for .request, .ready or .start + _wasSentByOwnDevice(event) { + if (!this._wasSentByOwnUser(event)) { return false; } const content = event.getContent(); diff --git a/src/models/event-timeline-set.js b/src/models/event-timeline-set.js index afe82719596..948f6c77839 100644 --- a/src/models/event-timeline-set.js +++ b/src/models/event-timeline-set.js @@ -490,8 +490,9 @@ EventTimelineSet.prototype.addEventsToTimeline = function(events, toStartOfTimel * * @param {MatrixEvent} event Event to be added * @param {string?} duplicateStrategy 'ignore' or 'replace' + * @param {boolean} fromCache whether the sync response came from cache */ -EventTimelineSet.prototype.addLiveEvent = function(event, duplicateStrategy) { +EventTimelineSet.prototype.addLiveEvent = function(event, duplicateStrategy, fromCache) { if (this._filter) { const events = this._filter.filterRoomTimeline([event]); if (!events.length) { @@ -529,7 +530,7 @@ EventTimelineSet.prototype.addLiveEvent = function(event, duplicateStrategy) { return; } - this.addEventToTimeline(event, this._liveTimeline, false); + this.addEventToTimeline(event, this._liveTimeline, false, fromCache); }; /** @@ -541,11 +542,12 @@ EventTimelineSet.prototype.addLiveEvent = function(event, duplicateStrategy) { * @param {MatrixEvent} event * @param {EventTimeline} timeline * @param {boolean} toStartOfTimeline + * @param {boolean} fromCache whether the sync response came from cache * * @fires module:client~MatrixClient#event:"Room.timeline" */ EventTimelineSet.prototype.addEventToTimeline = function(event, timeline, - toStartOfTimeline) { + toStartOfTimeline, fromCache) { const eventId = event.getId(); timeline.addEvent(event, toStartOfTimeline); this._eventIdToTimeline[eventId] = timeline; @@ -555,7 +557,7 @@ EventTimelineSet.prototype.addEventToTimeline = function(event, timeline, const data = { timeline: timeline, - liveEvent: !toStartOfTimeline && timeline == this._liveTimeline, + liveEvent: !toStartOfTimeline && timeline == this._liveTimeline && !fromCache, }; this.emit("Room.timeline", event, this.room, Boolean(toStartOfTimeline), false, data); diff --git a/src/models/event.js b/src/models/event.js index 017acf176a8..afa9e4cab4d 100644 --- a/src/models/event.js +++ b/src/models/event.js @@ -154,6 +154,12 @@ export const MatrixEvent = function( * attempt may succeed) */ this._retryDecryption = false; + + /* If the event is a `m.key.verification.request` (or to_device `m.key.verification.start`) event, + * `Crypto` will set this the `VerificationRequest` for the event + * so it can be easily accessed from the timeline. + */ + this.verificationRequest = null; }; utils.inherits(MatrixEvent, EventEmitter); @@ -1054,6 +1060,10 @@ utils.extend(MatrixEvent.prototype, { encrypted: this.event, }; }, + + setVerificationRequest: function(request) { + this.verificationRequest = request; + }, }); diff --git a/src/models/room.js b/src/models/room.js index 62fdbb86af6..e362c4fc753 100644 --- a/src/models/room.js +++ b/src/models/room.js @@ -1067,10 +1067,11 @@ Room.prototype.removeFilteredTimelineSet = function(filter) { * * @param {MatrixEvent} event Event to be added * @param {string?} duplicateStrategy 'ignore' or 'replace' + * @param {boolean} fromCache whether the sync response came from cache * @fires module:client~MatrixClient#event:"Room.timeline" * @private */ -Room.prototype._addLiveEvent = function(event, duplicateStrategy) { +Room.prototype._addLiveEvent = function(event, duplicateStrategy, fromCache) { if (event.isRedaction()) { const redactId = event.event.redacts; @@ -1117,7 +1118,7 @@ Room.prototype._addLiveEvent = function(event, duplicateStrategy) { // add to our timeline sets for (let i = 0; i < this._timelineSets.length; i++) { - this._timelineSets[i].addLiveEvent(event, duplicateStrategy); + this._timelineSets[i].addLiveEvent(event, duplicateStrategy, fromCache); } // synthesize and inject implicit read receipts @@ -1427,9 +1428,10 @@ Room.prototype._revertRedactionLocalEcho = function(redactionEvent) { * this function will be ignored entirely, preserving the existing event in the * timeline. Events are identical based on their event ID only. * + * @param {boolean} fromCache whether the sync response came from cache * @throws If duplicateStrategy is not falsey, 'replace' or 'ignore'. */ -Room.prototype.addLiveEvents = function(events, duplicateStrategy) { +Room.prototype.addLiveEvents = function(events, duplicateStrategy, fromCache) { let i; if (duplicateStrategy && ["replace", "ignore"].indexOf(duplicateStrategy) === -1) { throw new Error("duplicateStrategy MUST be either 'replace' or 'ignore'"); @@ -1455,7 +1457,7 @@ Room.prototype.addLiveEvents = function(events, duplicateStrategy) { for (i = 0; i < events.length; i++) { // TODO: We should have a filter to say "only add state event // types X Y Z to the timeline". - this._addLiveEvent(events[i], duplicateStrategy); + this._addLiveEvent(events[i], duplicateStrategy, fromCache); } }; diff --git a/src/sync.js b/src/sync.js index b4894724d8b..24d419317b1 100644 --- a/src/sync.js +++ b/src/sync.js @@ -688,6 +688,7 @@ SyncApi.prototype._syncFromCache = async function(savedSync) { oldSyncToken: null, nextSyncToken, catchingUp: false, + fromCache: true, }; const data = { @@ -1237,7 +1238,8 @@ SyncApi.prototype._processSyncResponse = async function( } } - self._processRoomEvents(room, stateEvents, timelineEvents); + self._processRoomEvents(room, stateEvents, + timelineEvents, syncEventData.fromCache); // set summary after processing events, // because it will trigger a name calculation @@ -1564,10 +1566,11 @@ SyncApi.prototype._resolveInvites = function(room) { * @param {MatrixEvent[]} stateEventList A list of state events. This is the state * at the *START* of the timeline list if it is supplied. * @param {MatrixEvent[]} [timelineEventList] A list of timeline events. Lower index + * @param {boolean} fromCache whether the sync response came from cache * is earlier in time. Higher index is later. */ SyncApi.prototype._processRoomEvents = function(room, stateEventList, - timelineEventList) { + timelineEventList, fromCache) { // If there are no events in the timeline yet, initialise it with // the given state events const liveTimeline = room.getLiveTimeline(); @@ -1621,7 +1624,7 @@ SyncApi.prototype._processRoomEvents = function(room, stateEventList, // if the timeline has any state events in it. // This also needs to be done before running push rules on the events as they need // to be decorated with sender etc. - room.addLiveEvents(timelineEventList || []); + room.addLiveEvents(timelineEventList || [], null, fromCache); }; /**