diff --git a/spec/unit/crypto/verification/sas.spec.js b/spec/unit/crypto/verification/sas.spec.js index 716c0f00914..8d08b867e23 100644 --- a/spec/unit/crypto/verification/sas.spec.js +++ b/spec/unit/crypto/verification/sas.spec.js @@ -273,4 +273,127 @@ describe("SAS verification", function() { expect(bob.setDeviceVerified) .toNotHaveBeenCalled(); }); + + describe("verification in DM", function() { + let alice; + let bob; + let aliceSasEvent; + let bobSasEvent; + let aliceVerifier; + let bobPromise; + + beforeEach(async function() { + [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(); + }; + + aliceSasEvent = null; + bobSasEvent = null; + + bobPromise = new Promise((resolve, reject) => { + bob.on("event", async (event) => { + const content = event.getContent(); + if (event.getType() === "m.room.message" + && content.msgtype === "m.key.verification.request") { + expect(content.methods).toInclude(SAS.NAME); + expect(content.to).toBe(bob.getUserId()); + const verifier = bob.acceptVerificationDM(event, SAS.NAME); + verifier.on("show_sas", (e) => { + if (!e.sas.emoji || !e.sas.decimal) { + e.cancel(); + } else if (!aliceSasEvent) { + bobSasEvent = e; + } else { + try { + expect(e.sas).toEqual(aliceSasEvent.sas); + e.confirm(); + aliceSasEvent.confirm(); + } catch (error) { + e.mismatch(); + aliceSasEvent.mismatch(); + } + } + }); + await verifier.verify(); + resolve(); + } + }); + }); + + aliceVerifier = await alice.requestVerificationDM( + bob.getUserId(), "!room_id", [verificationMethods.SAS], + ); + aliceVerifier.on("show_sas", (e) => { + if (!e.sas.emoji || !e.sas.decimal) { + e.cancel(); + } else if (!bobSasEvent) { + aliceSasEvent = e; + } else { + try { + expect(e.sas).toEqual(bobSasEvent.sas); + e.confirm(); + bobSasEvent.confirm(); + } catch (error) { + e.mismatch(); + bobSasEvent.mismatch(); + } + } + }); + }); + + it("should verify a key", async function() { + await Promise.all([ + aliceVerifier.verify(), + bobPromise, + ]); + + // make sure Alice and Bob verified each other + expect(alice.setDeviceVerified) + .toHaveBeenCalledWith(bob.getUserId(), bob.deviceId); + expect(bob.setDeviceVerified) + .toHaveBeenCalledWith(alice.getUserId(), alice.deviceId); + }); + }); }); diff --git a/spec/unit/crypto/verification/util.js b/spec/unit/crypto/verification/util.js index 6e5622f3ec1..5b28a11c42a 100644 --- a/spec/unit/crypto/verification/util.js +++ b/spec/unit/crypto/verification/util.js @@ -43,6 +43,25 @@ 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({ + sender: this.getUserId(), // eslint-disable-line babel/no-invalid-this + type: type, + content: content, + room_id: room, + event_id: eventId, + }); + for (const client of clients) { + setTimeout( + () => client.emit("event", event), + 0, + ); + } + + return {event_id: eventId}; + }; for (const userInfo of userInfos) { const client = (new TestClient( @@ -54,6 +73,7 @@ export async function makeTestClients(userInfos, options) { } clientMap[userInfo.userId][userInfo.deviceId] = client; client.sendToDevice = sendToDevice; + client.sendEvent = sendEvent; clients.push(client); } diff --git a/src/client.js b/src/client.js index c59df9c71a5..ec0ee39b2a5 100644 --- a/src/client.js +++ b/src/client.js @@ -785,6 +785,40 @@ async function _setDeviceVerification( client.emit("deviceVerificationChanged", userId, deviceId, dev); } +/** + * Request a key verification from another user, using a DM. + * + * @param {string} userId the user to request verification with + * @param {string} roomId the room to use for verification + * @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.requestVerificationDM = function(userId, roomId, methods) { + if (this._crypto === null) { + throw new Error("End-to-end encryption disabled"); + } + 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. * diff --git a/src/crypto/index.js b/src/crypto/index.js index ccde83c6e58..11b6ff07ae9 100644 --- a/src/crypto/index.js +++ b/src/crypto/index.js @@ -756,6 +756,132 @@ Crypto.prototype.setDeviceVerification = async function( }; +function verificationEventHandler(target, userId, roomId, eventId) { + return function(event) { + // listen for events related to this verification + if (event.getRoomId() !== roomId + || event.getSender() !== userId) { + return; + } + const content = event.getContent(); + if (!content["m.relates_to"]) { + return; + } + const relatesTo + = content["m.relationship"] || content["m.relates_to"]; + if (!relatesTo.rel_type + || relatesTo.rel_type !== "m.reference" + || !relatesTo.event_id + || relatesTo.event_id !== eventId) { + return; + } + + // the event seems to be related to this verification, so pass it on to + // the verification handler + target.handleEvent(event); + }; +} + +Crypto.prototype.requestVerificationDM = async function(userId, roomId, methods) { + let methodMap; + if (methods) { + methodMap = new Map(); + for (const method of methods) { + if (typeof method === "string") { + methodMap.set(method, defaultVerificationMethods[method]); + } else if (method.NAME) { + methodMap.set(method.NAME, method); + } + } + } else { + methodMap = this._baseApis._crypto._verificationMethods; + } + + let eventId = undefined; + const listenPromise = new Promise((_resolve, _reject) => { + const listener = (event) => { + // listen for events related to this verification + if (event.getRoomId() !== roomId + || event.getSender() !== userId) { + return; + } + const relatesTo = event.getRelation(); + if (!relatesTo || !relatesTo.rel_type + || relatesTo.rel_type !== "m.reference" + || !relatesTo.event_id + || relatesTo.event_id !== eventId) { + return; + } + + const content = event.getContent(); + // the event seems to be related to this verification + switch (event.getType()) { + case "m.key.verification.start": { + const verifier = new (methodMap.get(content.method))( + this._baseApis, userId, content.from_device, eventId, + roomId, event, + ); + verifier.handler = verificationEventHandler( + verifier, userId, roomId, eventId, + ); + // this handler gets removed when the verification finishes + // (see the verify method of crypto/verification/Base.js) + this._baseApis.on("event", verifier.handler); + resolve(verifier); + break; + } + case "m.key.verification.cancel": { + reject(event); + break; + } + } + }; + this._baseApis.on("event", listener); + + const resolve = (...args) => { + this._baseApis.off("event", listener); + _resolve(...args); + }; + const reject = (...args) => { + this._baseApis.off("event", listener); + _reject(...args); + }; + }); + + const res = await this._baseApis.sendEvent( + roomId, "m.room.message", + { + body: this._baseApis.getUserId() + " is requesting to verify " + + "your key, but your client does not support in-chat key " + + "verification. You will need to use legacy key " + + "verification to verify keys.", + msgtype: "m.key.verification.request", + to: userId, + from_device: this._baseApis.getDeviceId(), + methods: [...methodMap.keys()], + }, + ); + eventId = res.event_id; + + return listenPromise; +}; + +Crypto.prototype.acceptVerificationDM = function(event, Method) { + if (typeof(Method) === "string") { + Method = defaultVerificationMethods[Method]; + } + const content = event.getContent(); + const verifier = new Method( + this._baseApis, event.getSender(), content.from_device, event.getId(), + event.getRoomId(), + ); + verifier.handler = verificationEventHandler( + verifier, event.getSender(), event.getRoomId(), event.getId(), + ); + this._baseApis.on("event", verifier.handler); + return verifier; +}; + Crypto.prototype.requestVerification = function(userId, methods, devices) { if (!methods) { // .keys() returns an iterator, so we need to explicitly turn it into an array @@ -803,20 +929,7 @@ Crypto.prototype.beginKeyVerification = function( 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)) { + if (this._verificationMethods.has(method)) { const verifier = new (this._verificationMethods.get(method))( this._baseApis, userId, deviceId, transactionId, ); @@ -1826,22 +1939,6 @@ Crypto.prototype._onKeyVerificationStart = function(event) { 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, @@ -1896,8 +1993,6 @@ Crypto.prototype._onKeyVerificationStart = function(event) { 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); diff --git a/src/crypto/verification/Base.js b/src/crypto/verification/Base.js index 5219b0124c5..dbb244f295d 100644 --- a/src/crypto/verification/Base.js +++ b/src/crypto/verification/Base.js @@ -47,42 +47,54 @@ export default class VerificationBase extends EventEmitter { * * @param {string} transactionId the transaction ID to be used when sending events * - * @param {object} startEvent the m.key.verification.start event that + * @param {string} [roomId] the room to use for verification + * + * @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 + * @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) { + constructor(baseApis, userId, deviceId, transactionId, roomId, startEvent, request) { super(); this._baseApis = baseApis; this.userId = userId; this.deviceId = deviceId; this.transactionId = transactionId; - this.startEvent = startEvent; - this.request = request; + if (typeof(roomId) === "string" || roomId instanceof String) { + this.roomId = roomId; + this.startEvent = startEvent; + this.request = request; + } else { + // if room ID was omitted, but start event and request were not + this.startEvent= roomId; + this.request = startEvent; + } this.cancelled = false; - this._parent = parent; this._done = false; this._promise = null; this._transactionTimeoutTimer = null; // At this point, the verification request was received so start the timeout timer. this._resetTimer(); + + if (this.roomId) { + this._send = this._sendMessage; + } else { + this._send = this._sendToDevice; + } } _resetTimer() { - console.log("Refreshing/starting the verification transaction timeout timer"); + logger.info("Refreshing/starting the verification transaction timeout timer"); if (this._transactionTimeoutTimer !== null) { clearTimeout(this._transactionTimeoutTimer); } this._transactionTimeoutTimer = setTimeout(() => { - if (!this._done && !this.cancelled) { - console.log("Triggering verification timeout"); - this.cancel(timeoutException); - } + if (!this._done && !this.cancelled) { + logger.info("Triggering verification timeout"); + this.cancel(timeoutException); + } }, 10 * 60 * 1000); // 10 minutes } @@ -93,6 +105,8 @@ export default class VerificationBase extends EventEmitter { } } + /* send a message to the other participant, using to-device messages + */ _sendToDevice(type, content) { if (this._done) { return Promise.reject(new Error("Verification is already done")); @@ -103,6 +117,21 @@ export default class VerificationBase extends EventEmitter { }); } + /* send a message to the other participant, using in-roomm messages + */ + _sendMessage(type, content) { + if (this._done) { + return Promise.reject(new Error("Verification is already done")); + } + // FIXME: if MSC1849 decides to use m.relationship instead of + // m.relates_to, we should follow suit here + content["m.relates_to"] = { + rel_type: "m.reference", + event_id: this.transactionId, + }; + return this._baseApis.sendEvent(this.roomId, type, content); + } + _waitForEvent(type) { if (this._done) { return Promise.reject(new Error("Verification is already done")); @@ -140,6 +169,10 @@ export default class VerificationBase extends EventEmitter { done() { this._endTimer(); // always kill the activity timer if (!this._done) { + if (this.roomId) { + // verification in DM requires a done message + this._send("m.key.verification.done", {}); + } this._resolve(); } } @@ -153,7 +186,7 @@ export default class VerificationBase extends EventEmitter { // cancelled by the other user) if (e === timeoutException) { const timeoutEvent = newTimeoutError(); - this._sendToDevice(timeoutEvent.getType(), timeoutEvent.getContent()); + this._send(timeoutEvent.getType(), timeoutEvent.getContent()); } else if (e instanceof MatrixEvent) { const sender = e.getSender(); if (sender !== this.userId) { @@ -163,9 +196,9 @@ export default class VerificationBase extends EventEmitter { content.reason = content.reason || content.body || "Unknown reason"; content.transaction_id = this.transactionId; - this._sendToDevice("m.key.verification.cancel", content); + this._send("m.key.verification.cancel", content); } else { - this._sendToDevice("m.key.verification.cancel", { + this._send("m.key.verification.cancel", { code: "m.unknown", reason: content.body || "Unknown reason", transaction_id: this.transactionId, @@ -173,7 +206,7 @@ export default class VerificationBase extends EventEmitter { } } } else { - this._sendToDevice("m.key.verification.cancel", { + this._send("m.key.verification.cancel", { code: "m.unknown", reason: e.toString(), transaction_id: this.transactionId, @@ -206,11 +239,17 @@ export default class VerificationBase extends EventEmitter { this._resolve = (...args) => { this._done = true; this._endTimer(); + if (this.handler) { + this._baseApis.off("event", this.handler); + } resolve(...args); }; this._reject = (...args) => { this._done = true; this._endTimer(); + if (this.handler) { + this._baseApis.off("event", this.handler); + } reject(...args); }; }); diff --git a/src/crypto/verification/SAS.js b/src/crypto/verification/SAS.js index 5889c56bef2..a2af399cfbe 100644 --- a/src/crypto/verification/SAS.js +++ b/src/crypto/verification/SAS.js @@ -213,9 +213,10 @@ export default class SAS extends Base { message_authentication_codes: MAC_LIST, // FIXME: allow app to specify what SAS methods can be used short_authentication_string: SAS_LIST, - transaction_id: this.transactionId, }; - this._sendToDevice("m.key.verification.start", initialMessage); + // NOTE: this._send will modify initialMessage to include the + // transaction_id field, or the m.relationship/m.relates_to field + this._send("m.key.verification.start", initialMessage); let e = await this._waitForEvent("m.key.verification.accept"); @@ -235,7 +236,7 @@ export default class SAS extends Base { const hashCommitment = content.commitment; const olmSAS = new global.Olm.SAS(); try { - this._sendToDevice("m.key.verification.key", { + this._send("m.key.verification.key", { key: olmSAS.get_pubkey(), }); @@ -306,7 +307,7 @@ export default class SAS extends Base { const olmSAS = new global.Olm.SAS(); try { const commitmentStr = olmSAS.get_pubkey() + anotherjson.stringify(content); - this._sendToDevice("m.key.verification.accept", { + this._send("m.key.verification.accept", { key_agreement_protocol: keyAgreement, hash: hashMethod, message_authentication_code: macMethod, @@ -320,7 +321,7 @@ export default class SAS extends Base { // FIXME: make sure event is properly formed content = e.getContent(); olmSAS.set_their_key(content.key); - this._sendToDevice("m.key.verification.key", { + this._send("m.key.verification.key", { key: olmSAS.get_pubkey(), }); @@ -369,7 +370,7 @@ export default class SAS extends Base { keyId, baseInfo + "KEY_IDS", ); - this._sendToDevice("m.key.verification.mac", { mac, keys }); + this._send("m.key.verification.mac", { mac, keys }); } async _checkMAC(olmSAS, content, method) {