diff --git a/spec/unit/crypto/cross-signing.spec.js b/spec/unit/crypto/cross-signing.spec.js index adf239e92..7d8ec5129 100644 --- a/spec/unit/crypto/cross-signing.spec.js +++ b/spec/unit/crypto/cross-signing.spec.js @@ -46,6 +46,30 @@ describe("Cross Signing", function() { await global.Olm.init(); }); + it("should sign the master key with the device key", async function() { + const alice = await makeTestClient( + {userId: "@alice:example.com", deviceId: "Osborne2"}, + ); + alice.uploadDeviceSigningKeys = expect.createSpy() + .andCall(async (auth, keys) => { + await olmlib.verifySignature( + alice._crypto._olmDevice, keys.master_key, "@alice:example.com", + "Osborne2", alice._crypto._olmDevice.deviceEd25519Key, + ); + }); + alice.uploadKeySignatures = async () => {}; + // set Alice's cross-signing key + let privateKeys; + alice.on("cross-signing.savePrivateKeys", function(e) { + privateKeys = e; + }); + alice.on("cross-signing.getKey", function(e) { + e.done(privateKeys[e.type]); + }); + await alice.resetCrossSigningKeys(); + expect(alice.uploadDeviceSigningKeys).toHaveBeenCalled(); + }); + it("should upload a signature when a user is verified", async function() { const alice = await makeTestClient( {userId: "@alice:example.com", deviceId: "Osborne2"}, @@ -116,6 +140,23 @@ describe("Cross Signing", function() { e.done(selfSigningKey); }); + const uploadSigsPromise = new Promise((resolve, reject) => { + alice.uploadKeySignatures = expect.createSpy().andCall(async (content) => { + await olmlib.verifySignature( + alice._crypto._olmDevice, + content["@alice:example.com"]["nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk"], + "@alice:example.com", + "Osborne2", alice._crypto._olmDevice.deviceEd25519Key, + ); + olmlib.pkVerify( + content["@alice:example.com"]["Osborne2"], + "EmkqvokUn8p+vQAGZitOk4PWjp7Ukp3txV2TbMPEiBQ", + "@alice:example.com", + ); + resolve(); + }); + }); + const deviceInfo = alice._crypto._deviceList._devices["@alice:example.com"] .Osborne2; const aliceDevice = { @@ -203,11 +244,6 @@ describe("Cross Signing", function() { }, }, }, - { - method: "POST", - path: "/keys/signatures/upload", - data: {}, - }, ]; setHttpResponses(alice, responses, true, true); @@ -215,6 +251,7 @@ describe("Cross Signing", function() { // once ssk is confirmed, device key should be trusted await keyChangePromise; + await uploadSigsPromise; expect(alice.checkUserTrust("@alice:example.com")).toBe(6); expect(alice.checkDeviceTrust("@alice:example.com", "Osborne2")).toBe(7); }); @@ -654,4 +691,83 @@ describe("Cross Signing", function() { expect(alice.checkUserTrust("@bob:example.com")).toBe(4); expect(alice.checkDeviceTrust("@bob:example.com", "Dynabook")).toBe(4); }); + + it("should offer to upgrade device verifications to cross-signing", async function() { + const alice = await makeTestClient( + {userId: "@alice:example.com", deviceId: "Osborne2"} + ); + const bob = await makeTestClient( + {userId: "@bob:example.com", deviceId: "Dynabook"}, + ); + const privateKeys = {}; + + bob.uploadDeviceSigningKeys = async () => {}; + bob.uploadKeySignatures = async () => {}; + // set Bob's cross-signing key + bob.on("cross-signing.savePrivateKeys", function(e) { + privateKeys.bob = e; + }); + bob.on("cross-signing.getKey", function(e) { + e.done(privateKeys.bob[e.type]); + }); + await bob.resetCrossSigningKeys(); + alice._crypto._deviceList.storeDevicesForUser("@bob:example.com", { + Dynabook: { + algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"], + keys: { + "curve25519:Dynabook": bob._crypto._olmDevice.deviceCurve25519Key, + "ed25519:Dynabook": bob._crypto._olmDevice.deviceEd25519Key, + }, + verified: 1, + known: true, + } + }); + alice._crypto._deviceList.storeCrossSigningForUser( + "@bob:example.com", + bob._crypto._crossSigningInfo.toStorage(), + ); + + alice.uploadDeviceSigningKeys = async () => {}; + alice.uploadKeySignatures = async () => {}; + // set Alice's cross-signing key + alice.on("cross-signing.savePrivateKeys", function(e) { + privateKeys.alice = e; + }); + alice.on("cross-signing.getKey", function(e) { + e.done(privateKeys.alice[e.type]); + }); + // when alice sets up cross-signing, she should notice that bob's + // cross-signing key is signed by his Dynabook, which alice has + // verified, and ask if the device verification should be upgraded to a + // cross-signing verification + let upgradePromise = new Promise((resolve, reject) => { + alice.once("cross-signing.upgradeDeviceVerifications", async (e) => { + expect(e.users["@bob:example.com"]).toExist(); + await e.accept(["@bob:example.com"]); + resolve(); + }); + }); + await alice.resetCrossSigningKeys(); + await upgradePromise; + + expect(alice.checkUserTrust("@bob:example.com")).toBe(6); + + // "forget" that Bob is trusted + delete alice._crypto._deviceList._crossSigningInfo["@bob:example.com"] + .keys.master.signatures["@alice:example.com"]; + + expect(alice.checkUserTrust("@bob:example.com")).toBe(2); + + upgradePromise = new Promise((resolve, reject) => { + alice.once("cross-signing.upgradeDeviceVerifications", async (e) => { + expect(e.users["@bob:example.com"]).toExist(); + await e.accept(["@bob:example.com"]); + resolve(); + }); + }); + alice._crypto._deviceList.emit("userCrossSigningUpdated", "@bob:example.com"); + await upgradePromise; + + expect(alice.checkUserTrust("@bob:example.com")).toBe(6); + }); }); diff --git a/src/client.js b/src/client.js index f79b38bb2..feb834ffb 100644 --- a/src/client.js +++ b/src/client.js @@ -561,6 +561,7 @@ MatrixClient.prototype.initCrypto = async function() { "crypto.devicesUpdated", "cross-signing.savePrivateKeys", "cross-signing.getKey", + "cross-signing.upgradeDeviceVerifications", ]); logger.log("Crypto: initialising crypto object..."); @@ -4751,6 +4752,22 @@ module.exports.CRYPTO_ENABLED = CRYPTO_ENABLED; * provided if a previously provided key was invalid. */ +/** + * Fires when a device verification can be upgraded to a cross-signing + * verification. The handler should call the `accept` callback in order to + * perform the upgrade. + * @event module:client~MatrixClient#"cross-signing.upgradeDeviceVerifications" + * @param {object} data + * @param {object} data.users The users whose device verifications can be + * upgraded to cross-signing verifications. This will be a map of user IDs + * to objects with the properties `devices` (array of the user's devices + * that verified their cross-signing key), and `crossSigningInfo` (the + * user's cross-signing information) + * @param {object} data.accept a function to call to upgrade the device + * verifications. It should be called with an array of the user IDs who + * should be cross-signed. + */ + /** * Fires when a secret has been requested by another client. Clients should * ensure that the requesting device is allowed to have the secret. For diff --git a/src/crypto/index.js b/src/crypto/index.js index 01a8cbabd..5f538e9b7 100644 --- a/src/crypto/index.js +++ b/src/crypto/index.js @@ -306,6 +306,7 @@ Crypto.prototype.init = async function() { */ Crypto.prototype.resetCrossSigningKeys = async function(authDict, level) { await this._crossSigningInfo.resetKeys(level); + await this._signObject(this._crossSigningInfo.keys.master); await this._cryptoStore.doTxn( 'readwrite', [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { @@ -329,6 +330,91 @@ Crypto.prototype.resetCrossSigningKeys = async function(authDict, level) { [this._deviceId]: signedDevice, }, }); + + // check all users for signatures + // FIXME: do this in batches + const users = {}; + for (const [userId, crossSigningInfo] + of Object.entries(this._deviceList._crossSigningInfo)) { + const upgradeInfo = await this._checkForDeviceVerificationUpgrade( + userId, CrossSigningInfo.fromStorage(crossSigningInfo, userId), + ); + if (upgradeInfo) { + users[userId] = upgradeInfo; + } + } + this.emit("cross-signing.upgradeDeviceVerifications", { + users, + accept: async (upgradeUsers) => { + for (const userId of upgradeUsers) { + if (userId in users) { + await this._baseApis.setDeviceVerified( + userId, users[userId].crossSigningInfo.getId(), + ); + } + } + }, + }); +}; + +/** + * Check if a user's cross-signing key is a candidate for upgrading from device + * verification. + * + * @param {string} userId the user whose cross-signing information is to be checked + * @param {object} crossSigningInfo the cross-signing information to check + */ +Crypto.prototype._checkForDeviceVerificationUpgrade = async function( + userId, crossSigningInfo, +) { + // only upgrade if this is the first cross-signing key that we've seen for + // them, and if their cross-signing key isn't already verified + if (crossSigningInfo.fu + && !(this._crossSigningInfo.checkUserTrust(crossSigningInfo) & 2)) { + const devices = this._deviceList.getRawStoredDevicesForUser(userId); + const deviceIds = await this._checkForValidDeviceSignature( + userId, crossSigningInfo.keys.master, devices, + ); + if (deviceIds.length) { + return { + devices: deviceIds.map( + deviceId => DeviceInfo.fromStorage(devices[deviceId], deviceId), + ), + crossSigningInfo, + }; + } + } +}; + +/** + * Check if the cross-signing key is signed by a verified device. + * + * @param {string} userId the user ID whose key is being checked + * @param {object} key the key that is being checked + * @param {object} devices the user's devices. Should be a map from device ID + * to device info + */ +Crypto.prototype._checkForValidDeviceSignature = async function(userId, key, devices) { + const deviceIds = []; + if (devices && key.signatures && key.signatures[userId]) { + for (const signame of Object.keys(key.signatures[userId])) { + const [, deviceId] = signame.split(':', 2); + if (deviceId in devices + && devices[deviceId].verified === DeviceVerification.VERIFIED) { + try { + await olmlib.verifySignature( + this._olmDevice, + key, + userId, + deviceId, + devices[deviceId].keys[signame], + ); + deviceIds.push(deviceId); + } catch (e) {} + } + } + } + return deviceIds; }; /** @@ -410,7 +496,9 @@ Crypto.prototype.checkDeviceTrust = function(userId, deviceId) { */ Crypto.prototype._onDeviceListUserCrossSigningUpdated = async function(userId) { if (userId === this._userId) { - this.checkOwnCrossSigningTrust(); + await this._checkOwnCrossSigningTrust(); + } else { + await this._checkDeviceVerifications(userId); } }; @@ -418,7 +506,7 @@ Crypto.prototype._onDeviceListUserCrossSigningUpdated = async function(userId) { * Check the copy of our cross-signing key that we have in the device list and * see if we can get the private key. If so, mark it as trusted. */ -Crypto.prototype.checkOwnCrossSigningTrust = async function() { +Crypto.prototype._checkOwnCrossSigningTrust = async function() { const userId = this._userId; // If we see an update to our own master key, check it against the master @@ -489,6 +577,8 @@ Crypto.prototype.checkOwnCrossSigningTrust = async function() { }, ); + const keySignatures = {}; + if (oldSelfSigningId !== newCrossSigning.getId("self_signing")) { logger.info("Got new self-signing key", newCrossSigning.getId("self_signing")); @@ -496,26 +586,60 @@ Crypto.prototype.checkOwnCrossSigningTrust = async function() { const signedDevice = await this._crossSigningInfo.signDevice( this._userId, device, ); - await this._baseApis.uploadKeySignatures({ - [this._userId]: { - [this._deviceId]: signedDevice, - }, - }); + keySignatures[this._deviceId] = signedDevice; } if (oldUserSigningId !== newCrossSigning.getId("user_signing")) { logger.info("Got new user-signing key", newCrossSigning.getId("user_signing")); } if (changed) { + await this._signObject(this._crossSigningInfo.keys.master); + keySignatures[this._crossSigningInfo.getId()] + = this._crossSigningInfo.keys.master; this._baseApis.emit("cross-signing.keysChanged", {}); } + if (Object.keys(keySignatures).length) { + await this._baseApis.uploadKeySignatures({[this._userId]: keySignatures}); + } + // Now we may be able to trust our key backup await this.checkKeyBackup(); // FIXME: if we previously trusted the backup, should we automatically sign // the backup with the new key (if not already signed)? }; +/** + * Check if the master key is signed by a verified device, and if so, prompt + * the application to mark it as verified. + * + * @param {string} userId the user ID whose key should be checked + */ +Crypto.prototype._checkDeviceVerifications = async function(userId) { + if (this._crossSigningInfo.keys.user_signing) { + const crossSigningInfo = this._deviceList.getStoredCrossSigningForUser(userId); + if (crossSigningInfo) { + const upgradeInfo = await this._checkForDeviceVerificationUpgrade( + userId, crossSigningInfo, + ); + if (upgradeInfo) { + this.emit("cross-signing.upgradeDeviceVerifications", { + users: { + [userId]: upgradeInfo, + }, + accept: async (users) => { + if (users.includes(userId)) { + await this._baseApis.setDeviceVerified( + userId, crossSigningInfo.getId(), + ); + } + }, + }); + } + } + } +}; + /** * Check the server for an active key backup and * if one is present and has a valid signature from