You've already forked matrix-js-sdk
mirror of
https://github.com/matrix-org/matrix-js-sdk.git
synced 2026-01-03 23:22:30 +03:00
initial implementation of secret storage and sharing
This commit is contained in:
153
spec/unit/crypto/secrets.spec.js
Normal file
153
spec/unit/crypto/secrets.spec.js
Normal file
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
import '../../olm-loader';
|
||||
|
||||
import expect from 'expect';
|
||||
import anotherjson from 'another-json';
|
||||
import { MatrixEvent } from '../../../lib/models/event';
|
||||
|
||||
import olmlib from '../../../lib/crypto/olmlib';
|
||||
|
||||
import TestClient from '../../TestClient';
|
||||
import { makeTestClients } from './verification/util';
|
||||
|
||||
import {HttpResponse, setHttpResponses} from '../../test-utils';
|
||||
|
||||
async function makeTestClient(userInfo, options) {
|
||||
const client = (new TestClient(
|
||||
userInfo.userId, userInfo.deviceId, undefined, undefined, options,
|
||||
)).client;
|
||||
|
||||
await client.initCrypto();
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
describe("Secrets", function() {
|
||||
if (!global.Olm) {
|
||||
console.warn('Not running megolm backup unit tests: libolm not present');
|
||||
return;
|
||||
}
|
||||
|
||||
beforeEach(async function() {
|
||||
await global.Olm.init();
|
||||
});
|
||||
|
||||
it("should store and retrieve a secret", async function() {
|
||||
const alice = await makeTestClient(
|
||||
{userId: "@alice:example.com", deviceId: "Osborne2"},
|
||||
);
|
||||
const secretStorage = alice._crypto._secretStorage;
|
||||
|
||||
const decryption = new global.Olm.PkDecryption();
|
||||
const pubkey = decryption.generate_key();
|
||||
const privkey = decryption.get_private_key();
|
||||
|
||||
alice.setAccountData = async function(eventType, contents, callback) {
|
||||
alice.store.storeAccountDataEvents([
|
||||
new MatrixEvent({
|
||||
type: eventType,
|
||||
content: contents,
|
||||
}),
|
||||
]);
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
alice.store.storeAccountDataEvents([
|
||||
new MatrixEvent({
|
||||
type: "m.secret_storage.key.abc",
|
||||
content: {
|
||||
algorithm: "m.secret_storage.v1.curve25519-aes-sha2",
|
||||
pubkey: pubkey,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(secretStorage.isStored("foo")).toBe(false);
|
||||
|
||||
await secretStorage.store("foo", "bar", ["abc"]);
|
||||
|
||||
expect(secretStorage.isStored("foo")).toBe(true);
|
||||
|
||||
const getKey = expect.createSpy().andCall(function(e) {
|
||||
expect(Object.keys(e.keys)).toEqual(["abc"]);
|
||||
e.done("abc", privkey);
|
||||
});
|
||||
alice.once("crypto.secrets.getKey", getKey);
|
||||
|
||||
expect(await secretStorage.get("foo")).toBe("bar");
|
||||
|
||||
expect(getKey).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should request secrets from other clients", async function() {
|
||||
const [osborne2, vax] = await makeTestClients(
|
||||
[
|
||||
{userId: "@alice:example.com", deviceId: "Osborne2"},
|
||||
{userId: "@alice:example.com", deviceId: "VAX"},
|
||||
],
|
||||
);
|
||||
|
||||
const vaxDevice = vax._crypto._olmDevice;
|
||||
const osborne2Device = osborne2._crypto._olmDevice;
|
||||
const secretStorage = osborne2._crypto._secretStorage;
|
||||
|
||||
osborne2._crypto._deviceList.storeDevicesForUser("@alice:example.com", {
|
||||
"VAX": {
|
||||
user_id: "@alice:example.com",
|
||||
device_id: "VAX",
|
||||
algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM],
|
||||
keys: {
|
||||
"ed25519:VAX": vaxDevice.deviceEd25519Key,
|
||||
"curve25519:VAX": vaxDevice.deviceCurve25519Key,
|
||||
},
|
||||
},
|
||||
});
|
||||
vax._crypto._deviceList.storeDevicesForUser("@alice:example.com", {
|
||||
"Osborne2": {
|
||||
user_id: "@alice:example.com",
|
||||
device_id: "Osborne2",
|
||||
algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM],
|
||||
keys: {
|
||||
"ed25519:Osborne2": osborne2Device.deviceEd25519Key,
|
||||
"curve25519:Osborne2": osborne2Device.deviceCurve25519Key,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
vax.once("crypto.secrets.request", function(e) {
|
||||
expect(e.name).toBe("foo");
|
||||
e.send("bar");
|
||||
});
|
||||
|
||||
await osborne2Device.generateOneTimeKeys(1);
|
||||
const otks = (await osborne2Device.getOneTimeKeys()).curve25519;
|
||||
await osborne2Device.markKeysAsPublished();
|
||||
|
||||
await vax._crypto._olmDevice.createOutboundSession(
|
||||
osborne2Device.deviceCurve25519Key,
|
||||
Object.values(otks)[0],
|
||||
);
|
||||
|
||||
const request = await secretStorage.request("foo", ["VAX"]);
|
||||
const secret = await request.promise;
|
||||
|
||||
expect(secret).toBe("bar");
|
||||
});
|
||||
});
|
||||
@@ -33,11 +33,16 @@ export async function makeTestClients(userInfos, options) {
|
||||
type: type,
|
||||
content: msg,
|
||||
});
|
||||
setTimeout(
|
||||
() => clientMap[userId][deviceId]
|
||||
.emit("toDeviceEvent", event),
|
||||
0,
|
||||
);
|
||||
const client = clientMap[userId][deviceId];
|
||||
if (event.isEncrypted()) {
|
||||
event.attemptDecryption(client._crypto)
|
||||
.then(() => client.emit("toDeviceEvent", event));
|
||||
} else {
|
||||
setTimeout(
|
||||
() => client.emit("toDeviceEvent", event),
|
||||
0,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
362
src/crypto/Secrets.js
Normal file
362
src/crypto/Secrets.js
Normal file
@@ -0,0 +1,362 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
import {EventEmitter} from 'events';
|
||||
import logger from '../logger';
|
||||
import olmlib from './olmlib';
|
||||
|
||||
/** Implements MSC-1946
|
||||
*/
|
||||
export default class SecretStorage extends EventEmitter {
|
||||
constructor(baseApis) {
|
||||
super();
|
||||
this._baseApis = baseApis;
|
||||
this._requests = {};
|
||||
this._incomingRequests = {};
|
||||
}
|
||||
|
||||
/** store an encrypted secret on the server
|
||||
*
|
||||
* @param {string} name The name of the secret
|
||||
* @param {string} secret The secret contents.
|
||||
* @param {Array} keys The IDs of the keys to use to encrypt the secret
|
||||
*/
|
||||
async store(name, secret, keys) {
|
||||
const encrypted = {};
|
||||
|
||||
for (const keyName of keys) {
|
||||
// get key information from key storage
|
||||
const keyInfo = this._baseApis.getAccountData(
|
||||
"m.secret_storage.key." + keyName,
|
||||
);
|
||||
if (!keyInfo) {
|
||||
continue;
|
||||
}
|
||||
const keyInfoContent = keyInfo.getContent();
|
||||
// FIXME: check signature of key info
|
||||
// encrypt secret, based on the algorithm
|
||||
switch (keyInfoContent.algorithm) {
|
||||
case "m.secret_storage.v1.curve25519-aes-sha2":
|
||||
{
|
||||
const encryption = new global.Olm.PkEncryption();
|
||||
try {
|
||||
encryption.set_recipient_key(keyInfoContent.pubkey);
|
||||
encrypted[keyName] = encryption.encrypt(secret);
|
||||
} finally {
|
||||
encryption.free();
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
logger.warn("unknown algorithm for secret storage key " + keyName
|
||||
+ ": " + keyInfoContent.algorithm);
|
||||
// do nothing if we don't understand the encryption algorithm
|
||||
}
|
||||
}
|
||||
|
||||
// save encrypted secret
|
||||
await this._baseApis.setAccountData(name, {encrypted});
|
||||
}
|
||||
|
||||
async get(name) {
|
||||
const secretInfo = this._baseApis.getAccountData(name);
|
||||
if (!secretInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const secretContent = secretInfo.getContent();
|
||||
|
||||
if (!secretContent.encrypted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// get possible keys to decrypt
|
||||
const keys = {};
|
||||
for (const keyName of Object.keys(secretContent.encrypted)) {
|
||||
// get key information from key storage
|
||||
const keyInfo = this._baseApis.getAccountData(
|
||||
"m.secret_storage.key." + keyName,
|
||||
).getContent();
|
||||
const encInfo = secretContent.encrypted[keyName];
|
||||
switch (keyInfo.algorithm) {
|
||||
case "m.secret_storage.v1.curve25519-aes-sha2":
|
||||
if (keyInfo.pubkey && encInfo.ciphertext && encInfo.mac
|
||||
&& encInfo.ephemeral) {
|
||||
keys[keyName] = keyInfo;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// do nothing if we don't understand the encryption algorithm
|
||||
}
|
||||
}
|
||||
|
||||
// fetch private key from app
|
||||
let decryption;
|
||||
let keyName;
|
||||
let cleanUp;
|
||||
let error;
|
||||
do {
|
||||
[keyName, decryption, cleanUp] = await new Promise((resolve, reject) => {
|
||||
this._baseApis.emit("crypto.secrets.getKey", {
|
||||
keys,
|
||||
error,
|
||||
done: function(keyName, key) {
|
||||
// FIXME: interpret key?
|
||||
if (!keys[keyName]) {
|
||||
error = "Unknown key (your app is broken)";
|
||||
resolve([]);
|
||||
}
|
||||
switch (keys[keyName].algorithm) {
|
||||
case "m.secret_storage.v1.curve25519-aes-sha2":
|
||||
{
|
||||
const decryption = new global.Olm.PkDecryption();
|
||||
try {
|
||||
const pubkey = decryption.init_with_private_key(key);
|
||||
if (pubkey !== keys[keyName].pubkey) {
|
||||
error = "Key does not match";
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
decryption.free();
|
||||
error = "Invalid key";
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
resolve([
|
||||
keyName,
|
||||
decryption,
|
||||
decryption.free.bind(decryption),
|
||||
]);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
error = "The universe is broken";
|
||||
resolve([]);
|
||||
}
|
||||
},
|
||||
cancel: function(e) {
|
||||
reject(e || new Error("Cancelled"));
|
||||
},
|
||||
});
|
||||
});
|
||||
if (error) {
|
||||
logger.error("Error getting private key:", error);
|
||||
}
|
||||
} while (!keyName);
|
||||
|
||||
// decrypt secret
|
||||
try {
|
||||
const encInfo = secretContent.encrypted[keyName];
|
||||
switch (keys[keyName].algorithm) {
|
||||
case "m.secret_storage.v1.curve25519-aes-sha2":
|
||||
return decryption.decrypt(
|
||||
encInfo.ephemeral, encInfo.mac, encInfo.ciphertext,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
cleanUp();
|
||||
}
|
||||
}
|
||||
|
||||
isStored(name, checkKey) {
|
||||
// check if secret exists
|
||||
const secretInfo = this._baseApis.getAccountData(name);
|
||||
if (!secretInfo) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const secretContent = secretInfo.getContent();
|
||||
|
||||
if (!secretContent.encrypted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// check if secret is encrypted by a known/trusted secret and
|
||||
// encryption looks sane
|
||||
for (const keyName of Object.keys(secretContent.encrypted)) {
|
||||
// get key information from key storage
|
||||
const keyInfo = this._baseApis.getAccountData(
|
||||
"m.secret_storage.key." + keyName,
|
||||
).getContent();
|
||||
const encInfo = secretContent.encrypted[keyName];
|
||||
if (checkKey) {
|
||||
// FIXME: check signature on key
|
||||
}
|
||||
switch (keyInfo.algorithm) {
|
||||
case "m.secret_storage.v1.curve25519-aes-sha2":
|
||||
if (keyInfo.pubkey && encInfo.ciphertext && encInfo.mac
|
||||
&& encInfo.ephemeral) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// do nothing if we don't understand the encryption algorithm
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
request(name, devices) {
|
||||
const requestId = this._baseApis.makeTxnId();
|
||||
|
||||
const requestControl = this._requests[requestId] = {
|
||||
devices,
|
||||
};
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
requestControl.resolve = resolve;
|
||||
requestControl.reject = reject;
|
||||
});
|
||||
const cancel = (reason) => {
|
||||
// send cancellation event
|
||||
const cancelData = {
|
||||
action: "cancel_request",
|
||||
requesting_device_id: this._baseApis.deviceId,
|
||||
request_id: requestId,
|
||||
};
|
||||
const toDevice = {};
|
||||
for (const device of devices) {
|
||||
toDevice[device] = cancelData;
|
||||
}
|
||||
this._baseApis.sendToDevice("m.secret.request", {
|
||||
[this._baseApis.getUserId()]: toDevice,
|
||||
});
|
||||
|
||||
// and reject the promise so that anyone waiting on it will be
|
||||
// notified
|
||||
requestControl.reject(new Error(reason ||"Cancelled"));
|
||||
};
|
||||
|
||||
// send request to devices
|
||||
const requestData = {
|
||||
name,
|
||||
action: "request",
|
||||
requesting_device_id: this._baseApis.deviceId,
|
||||
request_id: requestId,
|
||||
};
|
||||
const toDevice = {};
|
||||
for (const device of devices) {
|
||||
toDevice[device] = requestData;
|
||||
}
|
||||
this._baseApis.sendToDevice("m.secret.request", {
|
||||
[this._baseApis.getUserId()]: toDevice,
|
||||
});
|
||||
|
||||
return {
|
||||
request_id: requestId,
|
||||
promise,
|
||||
cancel,
|
||||
};
|
||||
}
|
||||
|
||||
_onRequestReceived(event) {
|
||||
const sender = event.getSender();
|
||||
const content = event.getContent();
|
||||
if (sender !== this._baseApis.getUserId()
|
||||
|| !(content.name && content.action
|
||||
&& content.requesting_device_id && content.request_id)) {
|
||||
// ignore requests from anyone else, for now
|
||||
return;
|
||||
}
|
||||
const deviceId = content.requesting_device_id;
|
||||
// check if it's a cancel
|
||||
if (content.action === "cancel_request") {
|
||||
if (this._incomingRequests[deviceId]
|
||||
&& this._incomingRequests[deviceId][content.request_id]) {
|
||||
logger.info("received request cancellation for secret (" + sender
|
||||
+ ", " + deviceId + ", " + content.request_id + ")");
|
||||
this.baseApis.emit("crypto.secrets.request_cancelled", {
|
||||
user_id: sender,
|
||||
device_id: deviceId,
|
||||
request_id: content.request_id,
|
||||
});
|
||||
}
|
||||
} else if (content.action === "request") {
|
||||
// if from us and device is trusted (or else check trust)
|
||||
// check if we have the secret
|
||||
logger.info("received request for secret (" + sender
|
||||
+ ", " + deviceId + ", " + content.request_id + ")");
|
||||
this._baseApis.emit("crypto.secrets.request", {
|
||||
sender: sender,
|
||||
device_id: deviceId,
|
||||
request_id: content.request_id,
|
||||
name: content.name,
|
||||
device_trust: this._baseApis.checkDeviceTrust(sender, deviceId),
|
||||
send: async (secret) => {
|
||||
const payload = {
|
||||
type: "m.secret.share",
|
||||
content: {
|
||||
request_id: content.request_id,
|
||||
secret: secret,
|
||||
},
|
||||
};
|
||||
const encryptedContent = {
|
||||
algorithm: olmlib.OLM_ALGORITHM,
|
||||
sender_key: this._baseApis._crypto._olmDevice.deviceCurve25519Key,
|
||||
ciphertext: {},
|
||||
};
|
||||
await olmlib.encryptMessageForDevice(
|
||||
encryptedContent.ciphertext,
|
||||
this._baseApis.getUserId(),
|
||||
this._baseApis.deviceId,
|
||||
this._baseApis._crypto._olmDevice,
|
||||
sender,
|
||||
this._baseApis._crypto.getStoredDevice(sender, deviceId),
|
||||
payload,
|
||||
);
|
||||
const contentMap = {
|
||||
[sender]: {
|
||||
[deviceId]: encryptedContent,
|
||||
},
|
||||
};
|
||||
|
||||
this._baseApis.sendToDevice("m.room.encrypted", contentMap);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_onSecretReceived(event) {
|
||||
if (event.getSender() !== this._baseApis.getUserId()) {
|
||||
// we shouldn't be receiving secrets from anyone else, so ignore
|
||||
// because someone could be trying to send us bogus data
|
||||
return;
|
||||
}
|
||||
const content = event.getContent();
|
||||
logger.log("got secret share for request ", content.request_id);
|
||||
const requestControl = this._requests[content.request_id];
|
||||
if (requestControl) {
|
||||
// make sure that the device that sent it is one of the devices that
|
||||
// we requested from
|
||||
const deviceInfo = this._baseApis._crypto._deviceList.getDeviceByIdentityKey(
|
||||
olmlib.OLM_ALGORITHM,
|
||||
event.getSenderKey(),
|
||||
);
|
||||
if (!deviceInfo) {
|
||||
logger.log(
|
||||
"secret share from unknown device with key", event.getSenderKey(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!requestControl.devices.includes(deviceInfo.deviceId)) {
|
||||
logger.log("unsolicited secret share from device", deviceInfo.deviceId);
|
||||
return;
|
||||
}
|
||||
|
||||
requestControl.resolve(content.secret);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ const DeviceVerification = DeviceInfo.DeviceVerification;
|
||||
const DeviceList = require('./DeviceList').default;
|
||||
import { randomString } from '../randomstring';
|
||||
import { CrossSigningInfo } from './CrossSigning';
|
||||
import SecretStorage from './Secrets';
|
||||
|
||||
import OutgoingRoomKeyRequestManager from './OutgoingRoomKeyRequestManager';
|
||||
import IndexedDBCryptoStore from './store/indexeddb-crypto-store';
|
||||
@@ -205,6 +206,9 @@ export default function Crypto(baseApis, sessionStore, userId, deviceId,
|
||||
this._crossSigningInfo.on("cross-signing:getKey", (...args) => {
|
||||
this._baseApis.emit("cross-signing:getKey", ...args);
|
||||
});
|
||||
|
||||
this._secretStorage = new SecretStorage(baseApis);
|
||||
// TODO: expose SecretStorage methods
|
||||
}
|
||||
utils.inherits(Crypto, EventEmitter);
|
||||
|
||||
@@ -320,13 +324,16 @@ Crypto.prototype.checkUserTrust = function(userId) {
|
||||
* TODO: see checkUserTrust
|
||||
*/
|
||||
Crypto.prototype.checkDeviceTrust = function(userId, deviceId) {
|
||||
const userCrossSigning = this._deviceList.getStoredCrossSigningForUser(userId);
|
||||
const device = this._deviceList.getStoredDevice(userId, deviceId);
|
||||
let rv = 0;
|
||||
if (device.isVerified()) {
|
||||
|
||||
const device = this._deviceList.getStoredDevice(userId, deviceId);
|
||||
if (device && device.isVerified()) {
|
||||
rv |= 1;
|
||||
}
|
||||
rv |= this._crossSigningInfo.checkDeviceTrust(userCrossSigning, device) << 1;
|
||||
const userCrossSigning = this._deviceList.getStoredCrossSigningForUser(userId);
|
||||
if (device && userCrossSigning) {
|
||||
rv |= this._crossSigningInfo.checkDeviceTrust(userCrossSigning, device) << 1;
|
||||
}
|
||||
return rv;
|
||||
};
|
||||
|
||||
@@ -1015,16 +1022,6 @@ Crypto.prototype.saveDeviceList = function(delay) {
|
||||
return this._deviceList.saveIfDirty(delay);
|
||||
};
|
||||
|
||||
Crypto.prototype.setSskVerification = async function(userId, verified) {
|
||||
const ssk = this._deviceList.getRawStoredSskForUser(userId);
|
||||
if (!ssk) {
|
||||
throw new Error("No self-signing key found for user " + userId);
|
||||
}
|
||||
ssk.verified = verified;
|
||||
this._deviceList.storeSskForUser(userId, ssk);
|
||||
this._deviceList.saveIfDirty();
|
||||
};
|
||||
|
||||
/**
|
||||
* Update the blocked/verified state of the given device
|
||||
*
|
||||
@@ -1953,6 +1950,10 @@ Crypto.prototype._onToDeviceEvent = function(event) {
|
||||
this._onRoomKeyEvent(event);
|
||||
} else if (event.getType() == "m.room_key_request") {
|
||||
this._onRoomKeyRequestEvent(event);
|
||||
} else if (event.getType() === "m.secret.request") {
|
||||
this._secretStorage._onRequestReceived(event);
|
||||
} else if (event.getType() === "m.secret.share") {
|
||||
this._secretStorage._onSecretReceived(event);
|
||||
} else if (event.getType() === "m.key.verification.request") {
|
||||
this._onKeyVerificationRequest(event);
|
||||
} else if (event.getType() === "m.key.verification.start") {
|
||||
|
||||
Reference in New Issue
Block a user