/*
* Copyright 2016 OpenMarket Ltd
* Copyright 2017 Vector Creations Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.androidsdk.crypto;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.Looper;
import android.text.TextUtils;
import org.matrix.androidsdk.crypto.data.MXOlmInboundGroupSession2;
import org.matrix.androidsdk.rest.callback.SimpleApiCallback;
import org.matrix.androidsdk.rest.model.Sync.SyncResponse;
import org.matrix.androidsdk.util.Log;
import com.google.gson.JsonElement;
import com.google.gson.reflect.TypeToken;
import org.matrix.androidsdk.MXSession;
import org.matrix.androidsdk.crypto.algorithms.IMXDecrypting;
import org.matrix.androidsdk.crypto.algorithms.IMXEncrypting;
import org.matrix.androidsdk.crypto.data.MXDeviceInfo;
import org.matrix.androidsdk.crypto.data.MXEncryptEventContentResult;
import org.matrix.androidsdk.crypto.data.MXKey;
import org.matrix.androidsdk.crypto.data.MXOlmSessionResult;
import org.matrix.androidsdk.crypto.data.MXUsersDevicesMap;
import org.matrix.androidsdk.data.cryptostore.IMXCryptoStore;
import org.matrix.androidsdk.data.Room;
import org.matrix.androidsdk.data.RoomState;
import org.matrix.androidsdk.listeners.IMXNetworkEventListener;
import org.matrix.androidsdk.listeners.MXEventListener;
import org.matrix.androidsdk.network.NetworkConnectivityReceiver;
import org.matrix.androidsdk.rest.callback.ApiCallback;
import org.matrix.androidsdk.rest.model.Event;
import org.matrix.androidsdk.rest.model.EventContent;
import org.matrix.androidsdk.rest.model.MatrixError;
import org.matrix.androidsdk.rest.model.NewDeviceContent;
import org.matrix.androidsdk.rest.model.RoomKeyContent;
import org.matrix.androidsdk.rest.model.RoomMember;
import org.matrix.androidsdk.rest.model.crypto.KeysUploadResponse;
import org.matrix.androidsdk.util.JsonUtils;
import java.lang.reflect.Constructor;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
/**
* A `MXCrypto` class instance manages the end-to-end crypto for a MXSession instance.
* <p>
* Messages posted by the user are automatically redirected to MXCrypto in order to be encrypted
* before sending.
* In the other hand, received events goes through MXCrypto for decrypting.
* MXCrypto maintains all necessary keys and their sharing with other devices required for the crypto.
* Specially, it tracks all room membership changes events in order to do keys updates.
*/
public class MXCrypto {
private static final String LOG_TAG = "MXCrypto";
// max number of keys to upload at once
// Creating keys can be an expensive operation so we limit the
// number we generate in one go to avoid blocking the application
// for too long.
private static final int ONE_TIME_KEY_GENERATION_MAX_NUMBER = 5;
// frequency with which to check & upload one-time keys
private static final long ONE_TIME_KEY_UPLOAD_PERIOD = 60 * 1000; // one minute
// The Matrix session.
private final MXSession mSession;
// the crypto store
public IMXCryptoStore mCryptoStore;
// MXEncrypting instance for each room.
private final HashMap<String, IMXEncrypting> mRoomEncryptors;
// A map from algorithm to MXDecrypting instance, for each room
private final HashMap<String, /* room id */
HashMap<String /* algorithm */, IMXDecrypting>> mRoomDecryptors;
// Our device keys
private MXDeviceInfo mMyDevice;
// The libolm wrapper.
private MXOlmDevice mOlmDevice;
private Map<String, Map<String, String>> mLastPublishedOneTimeKeys;
// the encryption is starting
private boolean mIsStarting;
// tell if the crypto is started
private boolean mIsStarted;
// the crypto background threads
private HandlerThread mEncryptingHandlerThread = null;
private Handler mEncryptingHandler = null;
private HandlerThread mDecryptingHandlerThread = null;
private Handler mDecryptingHandler = null;
// the UI thread
private Handler mUIHandler = null;
private NetworkConnectivityReceiver mNetworkConnectivityReceiver;
private final MXDeviceList mDevicesList;
private final IMXNetworkEventListener mNetworkListener = new IMXNetworkEventListener() {
@Override
public void onNetworkConnectionUpdate(boolean isConnected) {
if (isConnected && !isStarted()) {
Log.d(LOG_TAG, "Start MXCrypto because a network connection has been retrieved ");
start(false, null);
}
}
};
private final MXEventListener mEventListener = new MXEventListener() {
@Override
public void onToDeviceEvent(Event event) {
MXCrypto.this.onToDeviceEvent(event);
}
@Override
public void onLiveEvent(Event event, RoomState roomState) {
if (TextUtils.equals(event.getType(), Event.EVENT_TYPE_MESSAGE_ENCRYPTION)) {
onCryptoEvent(event);
}
}
};
// initialization callbacks
private final ArrayList<ApiCallback<Void>> mInitializationCallbacks = new ArrayList();
// Warn the user if some new devices are detected while encrypting a message.
private boolean mWarnOnUnknownDevices = true;
// tell if there is a OTK check in progress
private boolean mOneTimeKeyCheckInProgress = false;
// last OTK check timestamp
private long mLastOneTimeKeyCheck = 0;
/**
* Constructor
*
* @param matrixSession the session
* @param cryptoStore the crypto store
*/
public MXCrypto(MXSession matrixSession, IMXCryptoStore cryptoStore) {
mSession = matrixSession;
mCryptoStore = cryptoStore;
mOlmDevice = new MXOlmDevice(mCryptoStore);
mRoomEncryptors = new HashMap<>();
mRoomDecryptors = new HashMap<>();
String deviceId = mSession.getCredentials().deviceId;
// deviceId should always be defined
boolean refreshDevicesList = !TextUtils.isEmpty(deviceId);
if (TextUtils.isEmpty(deviceId)) {
// use the stored one
mSession.getCredentials().deviceId = deviceId = mCryptoStore.getDeviceId();
}
if (TextUtils.isEmpty(deviceId)) {
mSession.getCredentials().deviceId = deviceId = UUID.randomUUID().toString();
Log.d(LOG_TAG, "Warning: No device id in MXCredentials. An id was created. Think of storing it");
mCryptoStore.storeDeviceId(deviceId);
}
mMyDevice = new MXDeviceInfo(deviceId);
mMyDevice.userId = mSession.getMyUserId();
mDevicesList = new MXDeviceList(matrixSession, this);
HashMap<String, String> keys = new HashMap<>();
if (!TextUtils.isEmpty(mOlmDevice.getDeviceEd25519Key())) {
keys.put("ed25519:" + mSession.getCredentials().deviceId, mOlmDevice.getDeviceEd25519Key());
}
if (!TextUtils.isEmpty(mOlmDevice.getDeviceCurve25519Key())) {
keys.put("curve25519:" + mSession.getCredentials().deviceId, mOlmDevice.getDeviceCurve25519Key());
}
mMyDevice.keys = keys;
mMyDevice.algorithms = MXCryptoAlgorithms.sharedAlgorithms().supportedAlgorithms();
mMyDevice.mVerified = MXDeviceInfo.DEVICE_VERIFICATION_VERIFIED;
// Add our own deviceinfo to the store
Map<String, MXDeviceInfo> endToEndDevicesForUser = mCryptoStore.getUserDevices(mSession.getMyUserId());
HashMap<String, MXDeviceInfo> myDevices;
if (null != endToEndDevicesForUser) {
myDevices = new HashMap<>(endToEndDevicesForUser);
} else {
myDevices = new HashMap<>();
}
myDevices.put(mMyDevice.deviceId, mMyDevice);
mCryptoStore.storeUserDevices(mSession.getMyUserId(), myDevices);
mSession.getDataHandler().setCryptoEventsListener(mEventListener);
mEncryptingHandlerThread = new HandlerThread("MXCrypto_encrypting_" + mSession.getMyUserId(), Thread.MIN_PRIORITY);
mEncryptingHandlerThread.start();
mDecryptingHandlerThread = new HandlerThread("MXCrypto_decrypting_" + mSession.getMyUserId(), Thread.MIN_PRIORITY);
mDecryptingHandlerThread.start();
mUIHandler = new Handler(Looper.getMainLooper());
if (refreshDevicesList) {
// ensure to have the up-to-date devices list
// got some issues when upgrading from Riot < 0.6.4
mDevicesList.addPendingUsersWithNewDevices(Arrays.asList(mSession.getMyUserId()));
}
}
/**
* @return the encrypting thread handler
*/
public Handler getEncryptingThreadHandler() {
// mEncryptingHandlerThread was not yet ready
if (null == mEncryptingHandler) {
mEncryptingHandler = new Handler(mEncryptingHandlerThread.getLooper());
}
// fail to get the handler
// might happen if the thread is not yet ready
if (null == mEncryptingHandler) {
return mUIHandler;
}
return mEncryptingHandler;
}
/**
* @return the decrypting thread handler
*/
private Handler getDecryptingThreadHandler() {
// mDecryptingHandlerThread was not yet ready
if (null == mDecryptingHandler) {
mDecryptingHandler = new Handler(mDecryptingHandlerThread.getLooper());
}
// fail to get the handler
// might happen if the thread is not yet ready
if (null == mDecryptingHandler) {
return mUIHandler;
}
return mDecryptingHandler;
}
/**
* @return the UI thread handler
*/
public Handler getUIHandler() {
return mUIHandler;
}
public void setNetworkConnectivityReceiver(NetworkConnectivityReceiver networkConnectivityReceiver) {
mNetworkConnectivityReceiver = networkConnectivityReceiver;
}
/**
* @return true if some saved data is corrupted
*/
public boolean isCorrupted() {
return mCryptoStore.isCorrupted();
}
/**
* @return true if this instance has been released
*/
public boolean hasBeenReleased() {
return (null == mOlmDevice);
}
/**
* @return my device info
*/
public MXDeviceInfo getMyDevice() {
return mMyDevice;
}
/**
* @return the crypto store
*/
public IMXCryptoStore getCryptoStore() {
return mCryptoStore;
}
/**
* @return the deviceList
*/
public MXDeviceList getDeviceList() {
return mDevicesList;
}
/**
* Tell if the MXCrypto is started
*
* @return true if the crypto is started
*/
public boolean isStarted() {
return mIsStarted;
}
/**
* Tells if the MXCrypto is starting.
*
* @return true if the crypto is starting
*/
public boolean isStarting() {
return mIsStarting;
}
/**
* Start the crypto module.
* Device keys will be uploaded, then one time keys if there are not enough on the homeserver
* and, then, if this is the first time, this new device will be announced to all other users
* devices.
*
* @param aCallback the asynchronous callback
*/
public void start(final boolean isInitialSync, final ApiCallback<Void> aCallback) {
if ((null != aCallback) && (mInitializationCallbacks.indexOf(aCallback) < 0)) {
mInitializationCallbacks.add(aCallback);
}
if (mIsStarting) {
return;
}
// do not start if there is not network connection
if ((null != mNetworkConnectivityReceiver) && !mNetworkConnectivityReceiver.isConnected()) {
// wait that a valid network connection is retrieved
mNetworkConnectivityReceiver.removeEventListener(mNetworkListener);
mNetworkConnectivityReceiver.addEventListener(mNetworkListener);
return;
}
mIsStarting = true;
getEncryptingThreadHandler().post(new Runnable() {
@Override
public void run() {
uploadDeviceKeys(new ApiCallback<KeysUploadResponse>() {
private void onError() {
getUIHandler().postDelayed(new Runnable() {
@Override
public void run() {
start(isInitialSync, null);
}
}, 5);
}
@Override
public void onSuccess(KeysUploadResponse info) {
getEncryptingThreadHandler().post(new Runnable() {
@Override
public void run() {
if (!hasBeenReleased()) {
Log.d(LOG_TAG, "###########################################################");
Log.d(LOG_TAG, "uploadDeviceKeys done for " + mSession.getMyUserId());
Log.d(LOG_TAG, " - device id : " + mSession.getCredentials().deviceId);
Log.d(LOG_TAG, " - ed25519 : " + mOlmDevice.getDeviceEd25519Key());
Log.d(LOG_TAG, " - curve25519 : " + mOlmDevice.getDeviceCurve25519Key());
Log.d(LOG_TAG, " - oneTimeKeys: " + mLastPublishedOneTimeKeys); // They are
Log.d(LOG_TAG, "");
getEncryptingThreadHandler().post(new Runnable() {
@Override
public void run() {
maybeUploadOneTimeKeys(new ApiCallback<Void>() {
@Override
public void onSuccess(Void info) {
getEncryptingThreadHandler().post(new Runnable() {
@Override
public void run() {
// Make sure we process to-device messages before generating new one-time-keys #2782
checkDeviceAnnounced(new ApiCallback<Void>() {
@Override
public void onSuccess(Void info) {
if (null != mNetworkConnectivityReceiver) {
mNetworkConnectivityReceiver.removeEventListener(mNetworkListener);
}
mIsStarting = false;
mIsStarted = true;
for (ApiCallback<Void> callback : mInitializationCallbacks) {
final ApiCallback<Void> fCallback = callback;
getUIHandler().post(new Runnable() {
@Override
public void run() {
fCallback.onSuccess(null);
}
});
}
mInitializationCallbacks.clear();
if (isInitialSync) {
getEncryptingThreadHandler().post(new Runnable() {
@Override
public void run() {
// refresh the devices list for each known room members
getDeviceList().invalidateUserDeviceList(getE2eRoomMembers());
mDevicesList.refreshOutdatedDeviceLists();
}
});
}
}
@Override
public void onNetworkError(Exception e) {
Log.e(LOG_TAG, "## start failed : " + e.getMessage());
onError();
}
@Override
public void onMatrixError(MatrixError e) {
Log.e(LOG_TAG, "## start failed : " + e.getMessage());
onError();
}
@Override
public void onUnexpectedError(Exception e) {
Log.e(LOG_TAG, "## start failed : " + e.getMessage());
onError();
}
});
}
});
}
@Override
public void onNetworkError(Exception e) {
Log.e(LOG_TAG, "## start failed : " + e.getMessage());
onError();
}
@Override
public void onMatrixError(MatrixError e) {
Log.e(LOG_TAG, "## start failed : " + e.getMessage());
onError();
}
@Override
public void onUnexpectedError(Exception e) {
Log.e(LOG_TAG, "## start failed : " + e.getMessage());
onError();
}
});
}
});
}
}
});
}
@Override
public void onNetworkError(Exception e) {
Log.e(LOG_TAG, "## start failed : " + e.getMessage());
onError();
}
@Override
public void onMatrixError(MatrixError e) {
Log.e(LOG_TAG, "## start failed : " + e.getMessage());
onError();
}
@Override
public void onUnexpectedError(Exception e) {
Log.e(LOG_TAG, "## start failed : " + e.getMessage());
onError();
}
});
}
});
}
/**
* Close the crypto
*/
public void close() {
if (null != mEncryptingHandlerThread) {
mSession.getDataHandler().removeListener(mEventListener);
getEncryptingThreadHandler().post(new Runnable() {
@Override
public void run() {
if (null != mOlmDevice) {
mOlmDevice.release();
mOlmDevice = null;
}
mMyDevice = null;
mCryptoStore.close();
mCryptoStore = null;
if (null != mEncryptingHandlerThread) {
mEncryptingHandlerThread.quit();
mEncryptingHandlerThread = null;
}
}
});
getDecryptingThreadHandler().post(new Runnable() {
@Override
public void run() {
if (null != mDecryptingHandlerThread) {
mDecryptingHandlerThread.quit();
mDecryptingHandlerThread = null;
}
}
});
}
}
/**
* @return the olmdevice instance
*/
public MXOlmDevice getOlmDevice() {
return mOlmDevice;
}
/**
* A sync response has been received
*
* @param syncResponse the syncResponse
* @param fromToken the start sync token
* @param isCatchingUp true if there is a catch-up in progress.
*/
public void onSyncCompleted(final SyncResponse syncResponse, final String fromToken, final boolean isCatchingUp) {
getEncryptingThreadHandler().post(new Runnable() {
@Override
public void run() {
if (null != syncResponse.deviceLists) {
getDeviceList().invalidateUserDeviceList(syncResponse.deviceLists.changed);
}
if (isStarted()) {
// Make sure we process to-device messages before generating new one-time-keys #2782
mDevicesList.refreshOutdatedDeviceLists();
}
if (!isCatchingUp && isStarted()) {
maybeUploadOneTimeKeys();
}
}
});
}
/**
* Get the stored device keys for a user.
*
* @param userId the user to list keys for.
* @param callback the asynchronous callback
*/
public void getUserDevices(final String userId, final ApiCallback<List<MXDeviceInfo>> callback) {
getEncryptingThreadHandler().post(new Runnable() {
@Override
public void run() {
final List<MXDeviceInfo> list = getUserDevices(userId);
if (null != callback) {
getUIHandler().post(new Runnable() {
@Override
public void run() {
callback.onSuccess(list);
}
});
}
}
});
}
/**
* Find a device by curve25519 identity key
*
* @param userId the owner of the device.
* @param algorithm the encryption algorithm.
* @param senderKey the curve25519 key to match.
* @return the device info.
*/
public MXDeviceInfo deviceWithIdentityKey(final String senderKey, final String userId, final String algorithm) {
if (!hasBeenReleased()) {
if (!TextUtils.equals(algorithm, MXCryptoAlgorithms.MXCRYPTO_ALGORITHM_MEGOLM) && !TextUtils.equals(algorithm, MXCryptoAlgorithms.MXCRYPTO_ALGORITHM_OLM)) {
// We only deal in olm keys
return null;
}
if (!TextUtils.isEmpty(userId)) {
final ArrayList<MXDeviceInfo> result = new ArrayList<>();
final CountDownLatch lock = new CountDownLatch(1);
getDecryptingThreadHandler().post(new Runnable() {
@Override
public void run() {
List<MXDeviceInfo> devices = getUserDevices(userId);
if (null != devices) {
for (MXDeviceInfo device : devices) {
Set<String> keys = device.keys.keySet();
for (String keyId : keys) {
if (keyId.startsWith("curve25519:")) {
if (TextUtils.equals(senderKey, device.keys.get(keyId))) {
result.add(device);
}
}
}
}
}
lock.countDown();
}
});
try {
lock.await();
} catch (Exception e) {
Log.e(LOG_TAG, "## deviceWithIdentityKey() : failed " + e.getMessage());
}
return (result.size() > 0) ? result.get(0) : null;
}
}
// Doesn't match a known device
return null;
}
/**
* Provides the device information for a device id and an user Id
*
* @param userId the user id
* @param deviceId the device id
* @param callback the asynchronous callback
*/
public void getDeviceInfo(final String userId, final String deviceId, final ApiCallback<MXDeviceInfo> callback) {
getDecryptingThreadHandler().post(new Runnable() {
@Override
public void run() {
final MXDeviceInfo di;
if (!TextUtils.isEmpty(userId) && !TextUtils.isEmpty(deviceId)) {
di = mCryptoStore.getUserDevice(deviceId, userId);
} else {
di = null;
}
if (null != callback) {
getUIHandler().post(new Runnable() {
@Override
public void run() {
callback.onSuccess(di);
}
});
}
}
});
}
/**
* Set the devices as known
*
* @param devices the devices
* @param callback the as
*/
public void setDevicesKnown(final List<MXDeviceInfo> devices, final ApiCallback<Void> callback) {
if (hasBeenReleased()) {
return;
}
getEncryptingThreadHandler().post(new Runnable() {
@Override
public void run() {
// build a devices map
Map<String, List<String>> devicesIdListByUserId = new HashMap<>();
for (MXDeviceInfo di : devices) {
List<String> deviceIdsList = devicesIdListByUserId.get(di.userId);
if (null == deviceIdsList) {
deviceIdsList = new ArrayList<>();
devicesIdListByUserId.put(di.userId, deviceIdsList);
}
deviceIdsList.add(di.deviceId);
}
Set<String> userIds = devicesIdListByUserId.keySet();
for (String userId : userIds) {
Map<String, MXDeviceInfo> storedDeviceIDs = mCryptoStore.getUserDevices(userId);
// sanity checks
if (null != storedDeviceIDs) {
boolean isUpdated = false;
List<String> deviceIds = devicesIdListByUserId.get(userId);
for (String deviceId : deviceIds) {
MXDeviceInfo device = storedDeviceIDs.get(deviceId);
// assume if the device is either verified or blocked
// it means that the device is known
if (device.isUnknown()) {
device.mVerified = MXDeviceInfo.DEVICE_VERIFICATION_UNVERIFIED;
isUpdated = true;
}
}
if (isUpdated) {
mCryptoStore.storeUserDevices(userId, storedDeviceIDs);
}
}
}
if (null != callback) {
getUIHandler().post(new Runnable() {
@Override
public void run() {
callback.onSuccess(null);
}
});
}
}
});
}
/**
* Update the blocked/verified state of the given device
*
* @param verificationStatus the new verification status.
* @param deviceId the unique identifier for the device.
* @param userId the owner of the device.
*/
public void setDeviceVerification(final int verificationStatus, final String deviceId, final String userId, final ApiCallback<Void> callback) {
if (hasBeenReleased()) {
return;
}
final ArrayList<String> userRoomIds = new ArrayList<>();
Collection<Room> rooms = mSession.getDataHandler().getStore().getRooms();
for (Room room : rooms) {
if (room.isEncrypted()) {
RoomMember roomMember = room.getMember(userId);
// test if the user joins the room
if ((null != roomMember) && TextUtils.equals(roomMember.membership, RoomMember.MEMBERSHIP_JOIN)) {
userRoomIds.add(room.getRoomId());
}
}
}
getEncryptingThreadHandler().post(new Runnable() {
@Override
public void run() {
MXDeviceInfo device = mCryptoStore.getUserDevice(deviceId, userId);
// Sanity check
if (null == device) {
Log.e(LOG_TAG, "## setDeviceVerification() : Unknown device " + userId + ":" + deviceId);
if (null != callback) {
getUIHandler().post(new Runnable() {
@Override
public void run() {
callback.onSuccess(null);
}
});
}
return;
}
if (device.mVerified != verificationStatus) {
device.mVerified = verificationStatus;
mCryptoStore.storeUserDevice(userId, device);
}
if (null != callback) {
getUIHandler().post(new Runnable() {
@Override
public void run() {
callback.onSuccess(null);
}
});
}
}
});
}
/**
* Configure a room to use encryption.
* This method must be called in getEncryptingThreadHandler
*
* @param roomId the room id to enable encryption in.
* @param algorithm the encryption config for the room.
* @return true if the operation succeeds.
*/
private boolean setEncryptionInRoom(String roomId, String algorithm) {
if (hasBeenReleased()) {
return false;
}
// If we already have encryption in this room, we should ignore this event
// (for now at least. Maybe we should alert the user somehow?)
String existingAlgorithm = mCryptoStore.getRoomAlgorithm(roomId);
if (!TextUtils.isEmpty(existingAlgorithm) && !TextUtils.equals(existingAlgorithm, algorithm)) {
Log.e(LOG_TAG, "## setEncryptionInRoom() : Ignoring m.room.encryption event which requests a change of config in " + roomId);
return false;
}
Class<IMXEncrypting> encryptingClass = MXCryptoAlgorithms.sharedAlgorithms().encryptorClassForAlgorithm(algorithm);
if (null == encryptingClass) {
Log.e(LOG_TAG, "## setEncryptionInRoom() : Unable to encrypt with " + algorithm);
return false;
}
mCryptoStore.storeRoomAlgorithm(roomId, algorithm);
IMXEncrypting alg;
try {
Constructor<?> ctor = encryptingClass.getConstructors()[0];
alg = (IMXEncrypting) ctor.newInstance();
} catch (Exception e) {
Log.e(LOG_TAG, "## setEncryptionInRoom() : fail to load the class");
return false;
}
alg.initWithMatrixSession(mSession, roomId);
synchronized (mRoomEncryptors) {
mRoomEncryptors.put(roomId, alg);
}
// if encryption was not previously enabled in this room, we will have been
// ignoring new device events for these users so far. We may well have
// up-to-date lists for some users, for instance if we were sharing other
// e2e rooms with them, so there is room for optimisation here, but for now
// we just invalidate everyone in the room.
if (null == existingAlgorithm) {
Log.d(LOG_TAG, "Enabling encryption in " + roomId + " for the first time; invalidating device lists for all users therein");
Room room = mSession.getDataHandler().getRoom(roomId);
if (null != room) {
Collection<RoomMember> members = room.getJoinedMembers();
List<String> userIds = new ArrayList<>();
for (RoomMember m : members) {
userIds.add(m.getUserId());
}
getDeviceList().invalidateUserDeviceList(userIds);
// the actual refresh happens once we've finished processing the sync,
// in _onSyncCompleted.
}
}
return true;
}
/**
* Tells if a room is encrypted
*
* @param roomId the room id
* @return true if the room is encrypted
*/
public boolean isRoomEncrypted(String roomId) {
boolean res = false;
if (null != roomId) {
synchronized (mRoomEncryptors) {
res = mRoomEncryptors.containsKey(roomId);
if (!res) {
Room room = mSession.getDataHandler().getRoom(roomId);
if (null != room) {
res = room.getLiveState().isEncrypted();
}
}
}
}
return res;
}
/**
* @return the stored device keys for a user.
*/
public List<MXDeviceInfo> getUserDevices(final String userId) {
Map<String, MXDeviceInfo> map = getCryptoStore().getUserDevices(userId);
return (null != map) ? new ArrayList<>(map.values()) : new ArrayList<MXDeviceInfo>();
}
/**
* Try to make sure we have established olm sessions for the given users.
* It must be called in getEncryptingThreadHandler() thread.
* The callback is called in the UI thread.
*
* @param users a list of user ids.
* @param callback the asynchronous callback
*/
public void ensureOlmSessionsForUsers(List<String> users, final ApiCallback<MXUsersDevicesMap<MXOlmSessionResult>> callback) {
Log.d(LOG_TAG, "## ensureOlmSessionsForUsers() : ensureOlmSessionsForUsers " + users);
HashMap<String /* userId */, ArrayList<MXDeviceInfo>> devicesByUser = new HashMap<>();
for (String userId : users) {
devicesByUser.put(userId, new ArrayList<MXDeviceInfo>());
List<MXDeviceInfo> devices = getUserDevices(userId);
for (MXDeviceInfo device : devices) {
String key = device.identityKey();
if (TextUtils.equals(key, mOlmDevice.getDeviceCurve25519Key())) {
// Don't bother setting up session to ourself
continue;
}
if (device.isVerified()) {
// Don't bother setting up sessions with blocked users
continue;
}
devicesByUser.get(userId).add(device);
}
}
ensureOlmSessionsForDevices(devicesByUser, callback);
}
/**
* Try to make sure we have established olm sessions for the given devices.
* It must be called in getCryptoHandler() thread.
* The callback is called in the UI thread.
*
* @param devicesByUser a map from userid to list of devices.
* @param callback teh asynchronous callback
*/
public void ensureOlmSessionsForDevices(final HashMap<String, ArrayList<MXDeviceInfo>> devicesByUser, final ApiCallback<MXUsersDevicesMap<MXOlmSessionResult>> callback) {
ArrayList<MXDeviceInfo> devicesWithoutSession = new ArrayList<>();
final MXUsersDevicesMap<MXOlmSessionResult> results = new MXUsersDevicesMap<>();
Set<String> userIds = devicesByUser.keySet();
for (String userId : userIds) {
ArrayList<MXDeviceInfo> deviceInfos = devicesByUser.get(userId);
for (MXDeviceInfo deviceInfo : deviceInfos) {
String deviceId = deviceInfo.deviceId;
String key = deviceInfo.identityKey();
String sessionId = mOlmDevice.getSessionId(key);
if (TextUtils.isEmpty(sessionId)) {
devicesWithoutSession.add(deviceInfo);
}
MXOlmSessionResult olmSessionResult = new MXOlmSessionResult(deviceInfo, sessionId);
results.setObject(olmSessionResult, userId, deviceId);
}
}
if (devicesWithoutSession.size() == 0) {
if (null != callback) {
getUIHandler().post(new Runnable() {
@Override
public void run() {
callback.onSuccess(results);
}
});
}
return;
}
// Prepare the request for claiming one-time keys
MXUsersDevicesMap<String> usersDevicesToClaim = new MXUsersDevicesMap<>();
final String oneTimeKeyAlgorithm = MXKey.KEY_SIGNED_CURVE_25519_TYPE;
for (MXDeviceInfo device : devicesWithoutSession) {
usersDevicesToClaim.setObject(oneTimeKeyAlgorithm, device.userId, device.deviceId);
}
// TODO: this has a race condition - if we try to send another message
// while we are claiming a key, we will end up claiming two and setting up
// two sessions.
//
// That should eventually resolve itself, but it's poor form.
Log.d(LOG_TAG, "## claimOneTimeKeysForUsersDevices() : " + usersDevicesToClaim);
mSession.getCryptoRestClient().claimOneTimeKeysForUsersDevices(usersDevicesToClaim, new ApiCallback<MXUsersDevicesMap<MXKey>>() {
@Override
public void onSuccess(final MXUsersDevicesMap<MXKey> oneTimeKeys) {
getEncryptingThreadHandler().post(new Runnable() {
@Override
public void run() {
try {
Log.d(LOG_TAG, "## claimOneTimeKeysForUsersDevices() : keysClaimResponse.oneTimeKeys: " + oneTimeKeys);
Set<String> userIds = devicesByUser.keySet();
for (String userId : userIds) {
ArrayList<MXDeviceInfo> deviceInfos = devicesByUser.get(userId);
for (MXDeviceInfo deviceInfo : deviceInfos) {
MXKey oneTimeKey = null;
List<String> deviceIds = oneTimeKeys.getUserDeviceIds(userId);
if (null != deviceIds) {
for (String deviceId : deviceIds) {
MXOlmSessionResult olmSessionResult = results.getObject(deviceId, userId);
if (null != olmSessionResult.mSessionId) {
// We already have a result for this device
continue;
}
MXKey key = oneTimeKeys.getObject(deviceId, userId);
if (TextUtils.equals(key.type, oneTimeKeyAlgorithm)) {
oneTimeKey = key;
}
if (null == oneTimeKey) {
Log.d(LOG_TAG, "## ensureOlmSessionsForDevices() : No one-time keys " + oneTimeKeyAlgorithm + " for device " + userId + " : " + deviceId);
continue;
}
// Update the result for this device in results
olmSessionResult.mSessionId = verifyKeyAndStartSession(oneTimeKey, userId, deviceInfo);
}
}
}
}
} catch (Exception e) {
Log.e(LOG_TAG, "## ensureOlmSessionsForDevices() " + e.getMessage());
}
if (!hasBeenReleased()) {
if (null != callback) {
getUIHandler().post(new Runnable() {
@Override
public void run() {
callback.onSuccess(results);
}
});
}
}
}
});
}
@Override
public void onNetworkError(Exception e) {
Log.e(LOG_TAG, "## ensureOlmSessionsForUsers(): claimOneTimeKeysForUsersDevices request failed" + e.getMessage());
if (null != callback) {
callback.onNetworkError(e);
}
}
@Override
public void onMatrixError(MatrixError e) {
Log.e(LOG_TAG, "## ensureOlmSessionsForUsers(): claimOneTimeKeysForUsersDevices request failed" + e.getLocalizedMessage());
if (null != callback) {
callback.onMatrixError(e);
}
}
@Override
public void onUnexpectedError(Exception e) {
Log.e(LOG_TAG, "## ensureOlmSessionsForUsers(): claimOneTimeKeysForUsersDevices request failed" + e.getMessage());
if (null != callback) {
callback.onUnexpectedError(e);
}
}
});
}
private String verifyKeyAndStartSession(MXKey oneTimeKey, String userId, MXDeviceInfo deviceInfo) {
String sessionId = null;
String deviceId = deviceInfo.deviceId;
String signKeyId = "ed25519:" + deviceId;
String signature = oneTimeKey.signatureForUserId(userId, signKeyId);
if (!TextUtils.isEmpty(signature) && !TextUtils.isEmpty(deviceInfo.fingerprint())) {
boolean isVerified = false;
String errorMessage = null;
try {
mOlmDevice.verifySignature(deviceInfo.fingerprint(), oneTimeKey.signalableJSONDictionary(), signature);
isVerified = true;
} catch (Exception e) {
errorMessage = e.getMessage();
}
// Check one-time key signature
if (isVerified) {
sessionId = getOlmDevice().createOutboundSession(deviceInfo.identityKey(), oneTimeKey.value);
if (!TextUtils.isEmpty(sessionId)) {
Log.d(LOG_TAG, "## verifyKeyAndStartSession() : Started new sessionid " + sessionId + " for device " + deviceInfo + "(theirOneTimeKey: " + oneTimeKey.value + ")");
} else {
// Possibly a bad key
Log.e(LOG_TAG, "## verifyKeyAndStartSession() : Error starting session with device " + userId + ":" + deviceId);
}
} else {
Log.e(LOG_TAG, "## verifyKeyAndStartSession() : Unable to verify signature on one-time key for device " + userId + ":" + deviceId + " Error " + errorMessage);
}
}
return sessionId;
}
/**
* Encrypt an event content according to the configuration of the room.
*
* @param eventContent the content of the event.
* @param eventType the type of the event.
* @param room the room the event will be sent.
* @param callback the asynchronous callback
*/
public void encryptEventContent(final JsonElement eventContent, final String eventType, final Room room, final ApiCallback<MXEncryptEventContentResult> callback) {
// wait that the crypto is really started
if (!isStarted()) {
Log.d(LOG_TAG, "## encryptEventContent() : wait after e2e init");
start(false, new ApiCallback<Void>() {
@Override
public void onSuccess(Void info) {
encryptEventContent(eventContent, eventType, room, callback);
}
@Override
public void onNetworkError(Exception e) {
Log.e(LOG_TAG, "## encryptEventContent() : onNetworkError while waiting to start e2e : " + e.getMessage());
if (null != callback) {
callback.onNetworkError(e);
}
}
@Override
public void onMatrixError(MatrixError e) {
Log.e(LOG_TAG, "## encryptEventContent() : onMatrixError while waiting to start e2e : " + e.getMessage());
if (null != callback) {
callback.onMatrixError(e);
}
}
@Override
public void onUnexpectedError(Exception e) {
Log.e(LOG_TAG, "## encryptEventContent() : onUnexpectedError while waiting to start e2e : " + e.getMessage());
if (null != callback) {
callback.onUnexpectedError(e);
}
}
});
return;
}
// just as you are sending a secret message?
final ArrayList<String> userdIds = new ArrayList<>();
Collection<RoomMember> joinedMembers = room.getJoinedMembers();
for (RoomMember m : joinedMembers) {
userdIds.add(m.getUserId());
}
getEncryptingThreadHandler().post(new Runnable() {
@Override
public void run() {
IMXEncrypting alg;
synchronized (mRoomEncryptors) {
alg = mRoomEncryptors.get(room.getRoomId());
}
if (null == alg) {
String algorithm = room.getLiveState().encryptionAlgorithm();
if (null != algorithm) {
if (setEncryptionInRoom(room.getRoomId(), algorithm)) {
synchronized (mRoomEncryptors) {
alg = mRoomEncryptors.get(room.getRoomId());
}
}
}
}
if (null != alg) {
final long t0 = System.currentTimeMillis();
Log.d(LOG_TAG, "## encryptEventContent() starts");
alg.encryptEventContent(eventContent, eventType, userdIds, new ApiCallback<JsonElement>() {
@Override
public void onSuccess(final JsonElement encryptedContent) {
Log.d(LOG_TAG, "## encryptEventContent() : succeeds after " + (System.currentTimeMillis() - t0) + " ms");
if (null != callback) {
callback.onSuccess(new MXEncryptEventContentResult(encryptedContent, Event.EVENT_TYPE_MESSAGE_ENCRYPTED));
}
}
@Override
public void onNetworkError(final Exception e) {
Log.e(LOG_TAG, "## encryptEventContent() : onNetworkError " + e.getMessage());
if (null != callback) {
callback.onNetworkError(e);
}
}
@Override
public void onMatrixError(final MatrixError e) {
Log.e(LOG_TAG, "## encryptEventContent() : onMatrixError " + e.getMessage());
if (null != callback) {
callback.onMatrixError(e);
}
}
@Override
public void onUnexpectedError(final Exception e) {
Log.e(LOG_TAG, "## encryptEventContent() : onUnexpectedError " + e.getMessage());
if (null != callback) {
callback.onUnexpectedError(e);
}
}
});
} else {
final String reason = String.format(MXCryptoError.UNABLE_TO_ENCRYPT_REASON, room.getLiveState().encryptionAlgorithm());
Log.e(LOG_TAG, "## encryptEventContent() : " + reason);
if (null != callback) {
getUIHandler().post(new Runnable() {
@Override
public void run() {
callback.onMatrixError(new MXCryptoError(MXCryptoError.UNABLE_TO_ENCRYPT_ERROR_CODE, MXCryptoError.UNABLE_TO_ENCRYPT, reason));
}
});
}
}
}
});
}
/**
* Decrypt a received event
*
* @param event the raw event.
* @param timeline the id of the timeline where the event is decrypted. It is used to prevent replay attack.
* @return true if the decryption was successful.
*/
public boolean decryptEvent(final Event event, final String timeline) {
if (null == event) {
Log.e(LOG_TAG, "## decryptEvent : null event");
return false;
}
final EventContent eventContent = event.getWireEventContent();
if (null == eventContent) {
Log.e(LOG_TAG, "## decryptEvent : empty event content");
return false;
}
final ArrayList<Boolean> results = new ArrayList<>();
final CountDownLatch lock = new CountDownLatch(1);
getDecryptingThreadHandler().post(new Runnable() {
@Override
public void run() {
boolean result = false;
IMXDecrypting alg = getRoomDecryptor(event.roomId, eventContent.algorithm);
if (null == alg) {
String reason = String.format(MXCryptoError.UNABLE_TO_DECRYPT_REASON, event.eventId, eventContent.algorithm);
Log.e(LOG_TAG, "## decryptEvent() : " + reason);
event.setCryptoError(new MXCryptoError(MXCryptoError.UNABLE_TO_DECRYPT_ERROR_CODE, MXCryptoError.UNABLE_TO_DECRYPT, reason));
} else {
result = alg.decryptEvent(event, timeline);
if (!result) {
Log.e(LOG_TAG, "## decryptEvent() : failed " + event.getCryptoError().getDetailedErrorDescription());
}
}
results.add(result);
lock.countDown();
}
});
try {
lock.await();
} catch (Exception e) {
Log.e(LOG_TAG, "## decryptEvent() : failed " + e.getMessage());
}
return (results.size() > 0) && results.get(0);
}
/**
* Reset replay attack data for the given timeline.
*
* @param timelineId the timeline id
*/
public void resetReplayAttackCheckInTimeline(final String timelineId) {
if ((null != timelineId) && (null != getOlmDevice())) {
getDecryptingThreadHandler().post(new Runnable() {
@Override
public void run() {
getOlmDevice().resetReplayAttackCheckInTimeline(timelineId);
}
});
}
}
/**
* Encrypt an event payload for a list of devices.
* This method must be called from the getCryptoHandler() thread.
*
* @param payloadFields fields to include in the encrypted payload.
* @param deviceInfos list of device infos to encrypt for.
* @return the content for an m.room.encrypted event.
*/
public Map<String, Object> encryptMessage(Map<String, Object> payloadFields, List<MXDeviceInfo> deviceInfos) {
if (hasBeenReleased()) {
return new HashMap<>();
}
HashMap<String, MXDeviceInfo> deviceInfoParticipantKey = new HashMap<>();
ArrayList<String> participantKeys = new ArrayList<>();
for (MXDeviceInfo di : deviceInfos) {
participantKeys.add(di.identityKey());
deviceInfoParticipantKey.put(di.identityKey(), di);
}
HashMap<String, Object> payloadJson = new HashMap<>(payloadFields);
payloadJson.put("sender", mSession.getMyUserId());
payloadJson.put("sender_device", mSession.getCredentials().deviceId);
// Include the Ed25519 key so that the recipient knows what
// device this message came from.
// We don't need to include the curve25519 key since the
// recipient will already know this from the olm headers.
// When combined with the device keys retrieved from the
// homeserver signed by the ed25519 key this proves that
// the curve25519 key and the ed25519 key are owned by
// the same device.
HashMap<String, String> keysMap = new HashMap<>();
keysMap.put("ed25519", mOlmDevice.getDeviceEd25519Key());
payloadJson.put("keys", keysMap);
HashMap<String, Object> ciphertext = new HashMap<>();
for (String deviceKey : participantKeys) {
String sessionId = mOlmDevice.getSessionId(deviceKey);
if (!TextUtils.isEmpty(sessionId)) {
Log.d(LOG_TAG, "Using sessionid " + sessionId + " for device " + deviceKey);
MXDeviceInfo deviceInfo = deviceInfoParticipantKey.get(deviceKey);
payloadJson.put("recipient", deviceInfo.userId);
HashMap<String, String> recipientsKeysMap = new HashMap<>();
recipientsKeysMap.put("ed25519", deviceInfo.fingerprint());
payloadJson.put("recipient_keys", recipientsKeysMap);
String payloadString = JsonUtils.convertToUTF8(JsonUtils.canonicalize(JsonUtils.getGson(false).toJsonTree(payloadJson)).toString());
ciphertext.put(deviceKey, mOlmDevice.encryptMessage(deviceKey, sessionId, payloadString));
}
}
HashMap<String, Object> res = new HashMap<>();
res.put("algorithm", MXCryptoAlgorithms.MXCRYPTO_ALGORITHM_OLM);
res.put("sender_key", mOlmDevice.getDeviceCurve25519Key());
res.put("ciphertext", ciphertext);
return res;
}
/**
* Provides the list of e2e rooms
*
* @return the list of e2e rooms
*/
private List<Room> getE2eRooms() {
List<Room> e2eRooms = new ArrayList<>();
// sanity checks
if ((null == mSession.getDataHandler()) || (null == mSession.getDataHandler().getStore())) {
return e2eRooms;
}
List<Room> rooms = new ArrayList<>(mSession.getDataHandler().getStore().getRooms());
for (Room r : rooms) {
if (r.isEncrypted()) {
RoomMember me = r.getMember(mSession.getMyUserId());
if (null != me) {
String membership = me.membership;
// ignore any rooms which we have left
if (TextUtils.equals(membership, RoomMember.MEMBERSHIP_JOIN) ||
TextUtils.equals(membership, RoomMember.MEMBERSHIP_INVITE)) {
e2eRooms.add(r);
}
}
}
}
return e2eRooms;
}
/**
* get the users we share an e2e-enabled room with
*
* @return {Object<string>} userid->userid map (should be a Set but argh ES6)
*/
private List<String> getE2eRoomMembers() {
HashSet<String> list = new HashSet<>();
List<Room> rooms = getE2eRooms();
for (Room r : rooms) {
Collection<RoomMember> activeMembers = r.getActiveMembers();
for (RoomMember m : activeMembers) {
// add only the matrix id
if (MXSession.PATTERN_CONTAIN_MATRIX_USER_IDENTIFIER.matcher(m.getUserId()).matches()) {
list.add(m.getUserId());
}
}
}
return new ArrayList<>(list);
}
/**
* Announce the device to the server.
* This method must be called from the getCryptoHandler() thread.
* The callback is called in the UI thread.
*
* @param callback the asynchronous callback.
*/
private void checkDeviceAnnounced(final ApiCallback<Void> callback) {
if (mCryptoStore.deviceAnnounced()) {
// Catch up on any m.new_device events which arrived during the initial sync.
mDevicesList.refreshOutdatedDeviceLists();
if (null != callback) {
getUIHandler().post(new Runnable() {
@Override
public void run() {
callback.onSuccess(null);
}
});
}
return;
}
// Catch up on any m.new_device events which arrived during the initial sync.
// And force download all devices keys the user already has.
mDevicesList.addPendingUsersWithNewDevices(Arrays.asList(mMyDevice.userId));
mDevicesList.refreshOutdatedDeviceLists();
// We need to tell all the devices in all the rooms we are members of that
// we have arrived.
// Build a list of rooms for each user.
HashMap<String, ArrayList<String>> roomsByUser = new HashMap<>();
List<Room> rooms = getE2eRooms();
for (Room room : rooms) {
// Ignore any rooms which we have left
RoomMember me = room.getMember(mSession.getMyUserId());
if ((null == me) || (!TextUtils.equals(me.membership, RoomMember.MEMBERSHIP_JOIN) && !TextUtils.equals(me.membership, RoomMember.MEMBERSHIP_INVITE))) {
continue;
}
Collection<RoomMember> members = room.getLiveState().getMembers();
for (RoomMember r : members) {
ArrayList<String> roomIds = roomsByUser.get(r.getUserId());
if (null == roomIds) {
roomIds = new ArrayList<>();
roomsByUser.put(r.getUserId(), roomIds);
}
roomIds.add(room.getRoomId());
}
}
// Build a per-device message for each user
MXUsersDevicesMap<Map<String, Object>> contentMap = new MXUsersDevicesMap<>();
for (String userId : roomsByUser.keySet()) {
HashMap<String, Map<String, Object>> map = new HashMap<>();
HashMap<String, Object> submap = new HashMap<>();
submap.put("device_id", mMyDevice.deviceId);
submap.put("rooms", roomsByUser.get(userId));
map.put("*", submap);
contentMap.setObjects(map, userId);
}
if (contentMap.getUserIds().size() > 0) {
mSession.getCryptoRestClient().sendToDevice(Event.EVENT_TYPE_NEW_DEVICE, contentMap, new ApiCallback<Void>() {
@Override
public void onSuccess(Void info) {
getEncryptingThreadHandler().post(new Runnable() {
@Override
public void run() {
Log.d(LOG_TAG, "## checkDeviceAnnounced Annoucements done");
mCryptoStore.storeDeviceAnnounced();
if (null != callback) {
getUIHandler().post(new Runnable() {
@Override
public void run() {
callback.onSuccess(null);
}
});
}
}
});
}
@Override
public void onNetworkError(Exception e) {
Log.e(LOG_TAG, "## checkDeviceAnnounced() : failed " + e.getMessage());
if (null != callback) {
callback.onNetworkError(e);
}
}
@Override
public void onMatrixError(MatrixError e) {
Log.e(LOG_TAG, "## checkDeviceAnnounced() : failed " + e.getMessage());
if (null != callback) {
callback.onMatrixError(e);
}
}
@Override
public void onUnexpectedError(Exception e) {
Log.e(LOG_TAG, "## checkDeviceAnnounced() : failed " + e.getMessage());
if (null != callback) {
callback.onUnexpectedError(e);
}
}
});
}
mCryptoStore.storeDeviceAnnounced();
if (null != callback) {
getUIHandler().post(new Runnable() {
@Override
public void run() {
callback.onSuccess(null);
}
});
}
}
/**
* Handle the 'toDevice' event
*
* @param event the event
*/
private void onToDeviceEvent(final Event event) {
if (TextUtils.equals(event.getType(), Event.EVENT_TYPE_ROOM_KEY)) {
getDecryptingThreadHandler().post(new Runnable() {
@Override
public void run() {
onRoomKeyEvent(event);
}
});
} else if (TextUtils.equals(event.getType(), Event.EVENT_TYPE_NEW_DEVICE)) {
getEncryptingThreadHandler().post(new Runnable() {
@Override
public void run() {
onNewDeviceEvent(event);
}
});
}
}
/**
* Handle a key event.
* This method must be called on getDecryptingThreadHandler() thread.
*
* @param event the key event.
*/
private void onRoomKeyEvent(Event event) {
// sanity check
if (null == event) {
Log.e(LOG_TAG, "## onRoomKeyEvent() : null event");
return;
}
RoomKeyContent roomKeyContent = JsonUtils.toRoomKeyContent(event.getContentAsJsonObject());
String roomId = roomKeyContent.room_id;
String algorithm = roomKeyContent.algorithm;
if (TextUtils.isEmpty(roomId) || TextUtils.isEmpty(algorithm)) {
Log.e(LOG_TAG, "## onRoomKeyEvent() : missing fields");
return;
}
IMXDecrypting alg = getRoomDecryptor(roomId, algorithm);
if (null == alg) {
Log.e(LOG_TAG, "## onRoomKeyEvent() : Unable to handle keys for " + algorithm);
return;
}
alg.onRoomKeyEvent(event);
}
/**
* Called when a new device announces itself.
* This method must be called on getEncryptingThreadHandler() thread.
*
* @param event the announcement event.
*/
private void onNewDeviceEvent(final Event event) {
String userId = event.getSender();
final NewDeviceContent newDeviceContent = JsonUtils.toNewDeviceContent(event.getContent());
if ((null == newDeviceContent.rooms) || (null == newDeviceContent.deviceId)) {
Log.e(LOG_TAG, "## onNewDeviceEvent() : new_device event missing keys");
return;
}
String deviceId = newDeviceContent.deviceId;
List<String> rooms = newDeviceContent.rooms;
Log.d(LOG_TAG, "## onNewDeviceEvent() m.new_device event from " + userId + ":" + deviceId + " for rooms " + rooms);
if (null != mCryptoStore.getUserDevice(deviceId, userId)) {
Log.e(LOG_TAG, "## onNewDeviceEvent() : known device; ignoring");
return;
}
mDevicesList.addPendingUsersWithNewDevices(Arrays.asList(userId));
}
/**
* Handle an m.room.encryption event.
*
* @param event the encryption event.
*/
private void onCryptoEvent(final Event event) {
final EventContent eventContent = event.getWireEventContent();
getEncryptingThreadHandler().post(new Runnable() {
@Override
public void run() {
setEncryptionInRoom(event.roomId, eventContent.algorithm);
}
});
}
/**
* Upload my user's device keys.
* This method must called on getEncryptingThreadHandler() thread.
* The callback will called on UI thread.
*
* @param callback the asynchronous callback
*/
private void uploadDeviceKeys(ApiCallback<KeysUploadResponse> callback) {
// Prepare the device keys data to send
// Sign it
String signature = mOlmDevice.signJSON(mMyDevice.signalableJSONDictionary());
HashMap<String, String> submap = new HashMap<>();
submap.put("ed25519:" + mMyDevice.deviceId, signature);
HashMap<String, Map<String, String>> map = new HashMap<>();
map.put(mSession.getMyUserId(), submap);
mMyDevice.signatures = map;
// For now, we set the device id explicitly, as we may not be using the
// same one as used in login.
mSession.getCryptoRestClient().uploadKeys(mMyDevice.JSONDictionary(), null, mMyDevice.deviceId, callback);
}
/**
* OTK upload loop
*
* @param numberToGenerate the number of key to generate
* @param callback the asynchronous callback
*/
private void uploadLoop(final int numberToGenerate, final ApiCallback<Void> callback) {
if (numberToGenerate <= 0) {
// If we don't need to generate any more keys then we are done.
if (null != callback) {
getUIHandler().post(new Runnable() {
@Override
public void run() {
callback.onSuccess(null);
}
});
}
return;
}
final int keysThisLoop = Math.min(numberToGenerate, ONE_TIME_KEY_GENERATION_MAX_NUMBER);
getOlmDevice().generateOneTimeKeys(keysThisLoop);
uploadOneTimeKeys(new ApiCallback<KeysUploadResponse>() {
@Override
public void onSuccess(KeysUploadResponse Response) {
getEncryptingThreadHandler().post(new Runnable() {
@Override
public void run() {
uploadLoop(numberToGenerate - keysThisLoop, callback);
}
});
}
@Override
public void onNetworkError(Exception e) {
if (null != callback) {
callback.onNetworkError(e);
}
}
@Override
public void onMatrixError(MatrixError e) {
if (null != callback) {
callback.onMatrixError(e);
}
}
@Override
public void onUnexpectedError(Exception e) {
if (null != callback) {
callback.onUnexpectedError(e);
}
}
});
}
/**
* Check if the OTK must be uploaded.
*/
private void maybeUploadOneTimeKeys() {
maybeUploadOneTimeKeys(null);
}
/**
* Check if the OTK must be uploaded.
*
* @param callback the asynchronous callback
*/
private void maybeUploadOneTimeKeys(final ApiCallback<Void> callback) {
if (mOneTimeKeyCheckInProgress) {
getUIHandler().post(new Runnable() {
@Override
public void run() {
if (null != callback) {
callback.onSuccess(null);
}
}
});
return;
}
if ((System.currentTimeMillis() - mLastOneTimeKeyCheck) < ONE_TIME_KEY_UPLOAD_PERIOD) {
// we've done a key upload recently.
getUIHandler().post(new Runnable() {
@Override
public void run() {
if (null != callback) {
callback.onSuccess(null);
}
}
});
return;
}
mLastOneTimeKeyCheck = System.currentTimeMillis();
mOneTimeKeyCheckInProgress = true;
// ask the server how many keys we have
mSession.getCryptoRestClient().uploadKeys(null, null, mMyDevice.deviceId, new ApiCallback<KeysUploadResponse>() {
private void uploadKeysDone(String errorMessage) {
if (null != errorMessage) {
Log.e(LOG_TAG, "## maybeUploadOneTimeKeys() : failed " + errorMessage);
}
mOneTimeKeyCheckInProgress = false;
}
@Override
public void onSuccess(final KeysUploadResponse keysUploadResponse) {
getEncryptingThreadHandler().post(new Runnable() {
@Override
public void run() {
if (!hasBeenReleased()) {
// We need to keep a pool of one time public keys on the server so that
// other devices can start conversations with us. But we can only store
// a finite number of private keys in the olm Account object.
// To complicate things further then can be a delay between a device
// claiming a public one time key from the server and it sending us a
// message. We need to keep the corresponding private key locally until
// we receive the message.
// But that message might never arrive leaving us stuck with duff
// private keys clogging up our local storage.
// So we need some kind of enginering compromise to balance all of
// these factors.
long keyCount = keysUploadResponse.oneTimeKeyCountsForAlgorithm("signed_curve25519");
// We then check how many keys we can store in the Account object.
long maxOneTimeKeys = getOlmDevice().getMaxNumberOfOneTimeKeys();
// Try to keep at most half that number on the server. This leaves the
// rest of the slots free to hold keys that have been claimed from the
// server but we haven't recevied a message for.
// If we run out of slots when generating new keys then olm will
// discard the oldest private keys first. This will eventually clean
// out stale private keys that won't receive a message.
int keyLimit = (int) Math.floor(maxOneTimeKeys / 2.0);
// We work out how many new keys we need to create to top up the server
// If there are too many keys on the server then we don't need to
// create any more keys.
int numberToGenerate = (int) Math.max(keyLimit - keyCount, 0);
uploadLoop(numberToGenerate, new ApiCallback<Void>() {
@Override
public void onSuccess(Void info) {
Log.d(LOG_TAG, "## maybeUploadOneTimeKeys() : succeeded");
uploadKeysDone(null);
getUIHandler().post(new Runnable() {
@Override
public void run() {
if (null != callback) {
callback.onSuccess(null);
}
}
});
}
@Override
public void onNetworkError(final Exception e) {
uploadKeysDone(e.getMessage());
getUIHandler().post(new Runnable() {
@Override
public void run() {
if (null != callback) {
callback.onNetworkError(e);
}
}
});
}
@Override
public void onMatrixError(final MatrixError e) {
uploadKeysDone(e.getMessage());
getUIHandler().post(new Runnable() {
@Override
public void run() {
if (null != callback) {
callback.onMatrixError(e);
}
}
});
}
@Override
public void onUnexpectedError(final Exception e) {
uploadKeysDone(e.getMessage());
getUIHandler().post(new Runnable() {
@Override
public void run() {
if (null != callback) {
callback.onUnexpectedError(e);
}
}
});
}
});
}
}
});
}
@Override
public void onNetworkError(final Exception e) {
uploadKeysDone(e.getMessage());
getUIHandler().post(new Runnable() {
@Override
public void run() {
if (null != callback) {
callback.onNetworkError(e);
}
}
});
}
@Override
public void onMatrixError(final MatrixError e) {
uploadKeysDone(e.getMessage());
getUIHandler().post(new Runnable() {
@Override
public void run() {
if (null != callback) {
callback.onMatrixError(e);
}
}
});
}
@Override
public void onUnexpectedError(final Exception e) {
uploadKeysDone(e.getMessage());
getUIHandler().post(new Runnable() {
@Override
public void run() {
if (null != callback) {
callback.onUnexpectedError(e);
}
}
});
}
});
}
/**
* Upload my user's one time keys.
* This method must called on getEncryptingThreadHandler() thread.
* The callback will called on UI thread.
*
* @param callback the asynchronous callback
*/
private void uploadOneTimeKeys(final ApiCallback<KeysUploadResponse> callback) {
final Map<String, Map<String, String>> oneTimeKeys = mOlmDevice.getOneTimeKeys();
HashMap<String, Object> oneTimeJson = new HashMap<>();
Map<String, String> curve25519Map = oneTimeKeys.get("curve25519");
if (null != curve25519Map) {
for (String key_id : curve25519Map.keySet()) {
HashMap<String, Object> k = new HashMap<>();
k.put("key", curve25519Map.get(key_id));
// the key is also signed
String signature = mOlmDevice.signJSON(k);
HashMap<String, String> submap = new HashMap<>();
submap.put("ed25519:" + mMyDevice.deviceId, signature);
HashMap<String, Map<String, String>> map = new HashMap<>();
map.put(mSession.getMyUserId(), submap);
k.put("signatures", map);
oneTimeJson.put("signed_curve25519:" + key_id, k);
}
}
// For now, we set the device id explicitly, as we may not be using the
// same one as used in login.
mSession.getCryptoRestClient().uploadKeys(null, oneTimeJson, mMyDevice.deviceId, new ApiCallback<KeysUploadResponse>() {
@Override
public void onSuccess(final KeysUploadResponse info) {
getEncryptingThreadHandler().post(new Runnable() {
@Override
public void run() {
if (!hasBeenReleased()) {
mLastPublishedOneTimeKeys = oneTimeKeys;
mOlmDevice.markKeysAsPublished();
if (null != callback) {
getUIHandler().post(new Runnable() {
@Override
public void run() {
callback.onSuccess(info);
}
});
}
}
}
});
}
@Override
public void onNetworkError(Exception e) {
if (null != callback) {
callback.onNetworkError(e);
}
}
@Override
public void onMatrixError(MatrixError e) {
if (null != callback) {
callback.onMatrixError(e);
}
}
@Override
public void onUnexpectedError(Exception e) {
if (null != callback) {
callback.onUnexpectedError(e);
}
}
});
}
/**
* Get a decryptor for a given room and algorithm.
* If we already have a decryptor for the given room and algorithm, return
* it. Otherwise try to instantiate it.
*
* @param roomId the room id
* @param algorithm the crypto algorithm
* @return the decryptor
*/
private IMXDecrypting getRoomDecryptor(String roomId, String algorithm) {
// sanity check
if (TextUtils.isEmpty(algorithm)) {
Log.e(LOG_TAG, "## getRoomDecryptor() : null algorithm");
return null;
}
if (null == mRoomDecryptors) {
Log.e(LOG_TAG, "## getRoomDecryptor() : null mRoomDecryptors");
return null;
}
IMXDecrypting alg = null;
if (!TextUtils.isEmpty(roomId)) {
synchronized (mRoomDecryptors) {
if (!mRoomDecryptors.containsKey(roomId)) {
mRoomDecryptors.put(roomId, new HashMap<String, IMXDecrypting>());
}
alg = mRoomDecryptors.get(roomId).get(algorithm);
}
if (null != alg) {
return alg;
}
}
Class<IMXDecrypting> decryptingClass = MXCryptoAlgorithms.sharedAlgorithms().decryptorClassForAlgorithm(algorithm);
if (null != decryptingClass) {
try {
Constructor<?> ctor = decryptingClass.getConstructors()[0];
alg = (IMXDecrypting) ctor.newInstance();
if (null != alg) {
alg.initWithMatrixSession(mSession);
if (!TextUtils.isEmpty(roomId)) {
synchronized (mRoomDecryptors) {
mRoomDecryptors.get(roomId).put(algorithm, alg);
}
}
}
} catch (Exception e) {
Log.e(LOG_TAG, "## getRoomDecryptor() : fail to load the class");
return null;
}
}
return alg;
}
/**
* Export the crypto keys
*
* @param password the password
* @param callback the exported keys
*/
public void exportRoomKeys(final String password, final ApiCallback<byte[]> callback) {
getDecryptingThreadHandler().post(new Runnable() {
@Override
public void run() {
ArrayList<Map<String, Object>> exportedSessions = new ArrayList<>();
List<MXOlmInboundGroupSession2> inboundGroupSessions = mCryptoStore.getInboundGroupSessions();
for (MXOlmInboundGroupSession2 session : inboundGroupSessions) {
Map<String, Object> map = session.exportKeys();
if (null != map) {
exportedSessions.add(map);
}
}
final byte[] encryptedRoomKeys;
try {
String allo = JsonUtils.getGson(false).toJsonTree(exportedSessions).toString();
encryptedRoomKeys = MXMegolmExportEncryption.encryptMegolmKeyFile(allo, password);
} catch (Exception e) {
callback.onUnexpectedError(e);
return;
}
getUIHandler().post(new Runnable() {
@Override
public void run() {
callback.onSuccess(encryptedRoomKeys);
}
});
}
});
}
/**
* Import the room keys
*
* @param roomKeysAsArray the room keys as array.
* @param password the password
* @param callback the asynchronous callback.
*/
public void importRoomKeys(final byte[] roomKeysAsArray, final String password, final ApiCallback<Void> callback) {
getDecryptingThreadHandler().post(new Runnable() {
@Override
public void run() {
long t0 = System.currentTimeMillis();
String roomKeys;
try {
roomKeys = MXMegolmExportEncryption.decryptMegolmKeyFile(roomKeysAsArray, password);
} catch (final Exception e) {
getUIHandler().post(new Runnable() {
@Override
public void run() {
callback.onUnexpectedError(e);
}
});
return;
}
List<Map<String, Object>> importedSessions;
long t1 = System.currentTimeMillis();
Log.d(LOG_TAG, "## importRoomKeys starts");
try {
importedSessions = JsonUtils.getGson(false).fromJson(roomKeys, new TypeToken<List<Map<String, Object>>>() {
}.getType());
} catch (final Exception e) {
Log.e(LOG_TAG, "## importRoomKeys failed " + e.getMessage());
getUIHandler().post(new Runnable() {
@Override
public void run() {
callback.onUnexpectedError(e);
}
});
return;
}
long t2 = System.currentTimeMillis();
Log.d(LOG_TAG, "## importRoomKeys retrieve " + importedSessions.size() + "sessions in " + (t1 - t0) + " ms");
for (int index = 0; index < importedSessions.size(); index++) {
Map<String, Object> map = importedSessions.get(index);
MXOlmInboundGroupSession2 session = mOlmDevice.importInboundGroupSession(map);
if ((null != session) && mRoomDecryptors.containsKey(session.mRoomId)) {
IMXDecrypting decrypting = mRoomDecryptors.get(session.mRoomId).get(map.get("algorithm"));
if (null != decrypting) {
try {
String sessionId = session.mSession.sessionIdentifier();
Log.d(LOG_TAG, "## importRoomKeys retrieve mSenderKey " + session.mSenderKey + " sessionId " + sessionId);
decrypting.onNewSession(session.mSenderKey, sessionId);
} catch (Exception e) {
Log.e(LOG_TAG, "## importRoomKeys() : onNewSession failed " + e.getMessage());
}
}
}
}
long t3 = System.currentTimeMillis();
Log.d(LOG_TAG, "## importRoomKeys : done in " + (t3 - t0) + " ms (" + importedSessions.size() + " sessions)");
Log.d(LOG_TAG, "## importRoomKeys : decryptMegolmKeyFile done in " + (t1 - t0) + " ms");
Log.d(LOG_TAG, "## importRoomKeys : JSON parsing " + (t2 - t1) + " ms");
Log.d(LOG_TAG, "## importRoomKeys : sessions import " + (t3 - t2) + " ms");
getUIHandler().post(new Runnable() {
@Override
public void run() {
callback.onSuccess(null);
}
});
}
});
}
/**
* Tells if the encryption must fail if some unknown devices are detected.
*
* @return true to warn when some unknown devices are detected.
*/
public boolean warnOnUnknownDevices() {
return mWarnOnUnknownDevices;
}
/**
* Update the warn status when some unknown devices are detected.
*
* @param warn true to warn when some unknown devices are detected.
*/
public void setWarnOnUnknownDevices(boolean warn) {
mWarnOnUnknownDevices = warn;
}
/**
* Provides the list of unknown devices
*
* @param devicesInRoom the devices map
* @return the unknown devices map
*/
public static MXUsersDevicesMap<MXDeviceInfo> getUnknownDevices(MXUsersDevicesMap<MXDeviceInfo> devicesInRoom) {
MXUsersDevicesMap<MXDeviceInfo> unknownDevices = new MXUsersDevicesMap<>();
List<String> userIds = devicesInRoom.getUserIds();
for (String userId : userIds) {
List<String> deviceIds = devicesInRoom.getUserDeviceIds(userId);
for (String deviceId : deviceIds) {
MXDeviceInfo deviceInfo = devicesInRoom.getObject(deviceId, userId);
if (deviceInfo.isUnknown()) {
unknownDevices.setObject(deviceInfo, userId, deviceId);
}
}
}
return unknownDevices;
}
/**
* Check if the user ids list have some unknown devices.
* A success means there is no unknown devices.
* If there are some unknown devices, a MXCryptoError.UNKNOWN_DEVICES_CODE exception is triggered.
*
* @param userIds the user ids list
* @param callback the asynchronous callback.
*/
public void checkUnknownDevices(List<String> userIds, final ApiCallback<Void> callback) {
// force the refresh to ensure that the devices list is up-to-date
mDevicesList.downloadKeys(userIds, true, new ApiCallback<MXUsersDevicesMap<MXDeviceInfo>>() {
@Override
public void onSuccess(MXUsersDevicesMap<MXDeviceInfo> devicesMap) {
MXUsersDevicesMap<MXDeviceInfo> unknownDevices = MXCrypto.getUnknownDevices(devicesMap);
if (unknownDevices.getMap().size() == 0) {
callback.onSuccess(null);
} else {
// trigger an an unknown devices exception
callback.onMatrixError(new MXCryptoError(MXCryptoError.UNKNOWN_DEVICES_CODE, MXCryptoError.UNABLE_TO_ENCRYPT, MXCryptoError.UNKNOWN_DEVICES_REASON, unknownDevices));
}
}
@Override
public void onNetworkError(Exception e) {
callback.onNetworkError(e);
}
@Override
public void onMatrixError(MatrixError e) {
callback.onMatrixError(e);
}
@Override
public void onUnexpectedError(Exception e) {
callback.onUnexpectedError(e);
}
});
}
/**
* Set the global override for whether the client should ever send encrypted
* messages to unverified devices.
* If false, it can still be overridden per-room.
* If true, it overrides the per-room settings.
*
* @param block true to unilaterally blacklist all
* @param callback the asynchronous callback.
*/
public void setGlobalBlacklistUnverifiedDevices(final boolean block, final ApiCallback<Void> callback) {
final String userId = mSession.getMyUserId();
final ArrayList<String> userRoomIds = new ArrayList<>();
Collection<Room> rooms = mSession.getDataHandler().getStore().getRooms();
for (Room room : rooms) {
if (room.isEncrypted()) {
RoomMember roomMember = room.getMember(userId);
// test if the user joins the room
if ((null != roomMember) && TextUtils.equals(roomMember.membership, RoomMember.MEMBERSHIP_JOIN)) {
userRoomIds.add(room.getRoomId());
}
}
}
getEncryptingThreadHandler().post(new Runnable() {
@Override
public void run() {
mCryptoStore.setGlobalBlacklistUnverifiedDevices(block);
getUIHandler().post(new Runnable() {
@Override
public void run() {
if (null != callback) {
callback.onSuccess(null);
}
}
});
}
});
}
/**
* Tells whether the client should ever send encrypted messages to unverified devices.
* The default value is false.
* This function must be called in the getEncryptingThreadHandler() thread.
*
* @return true to unilaterally blacklist all unverified devices.
*/
public boolean getGlobalBlacklistUnverifiedDevices() {
return mCryptoStore.getGlobalBlacklistUnverifiedDevices();
}
/**
* Tells whether the client should ever send encrypted messages to unverified devices.
* The default value is false.
* messages to unverified devices.
*
* @param callback the asynchronous callback
*/
public void getGlobalBlacklistUnverifiedDevices(final ApiCallback<Boolean> callback) {
getEncryptingThreadHandler().post(new Runnable() {
@Override
public void run() {
if (null != callback) {
final boolean status = getGlobalBlacklistUnverifiedDevices();
getUIHandler().post(new Runnable() {
@Override
public void run() {
callback.onSuccess(status);
}
});
}
}
});
}
/**
* Tells whether the client should encrypt messages only for the verified devices
* in this room.
* The default value is false.
* This function must be called in the getEncryptingThreadHandler() thread.
*
* @param roomId the room id
* @return true if the client should encrypt messages only for the verified devices.
*/
public boolean isRoomBlacklistUnverifiedDevices(String roomId) {
if (null != roomId) {
return mCryptoStore.getRoomsListBlacklistUnverifiedDevices().contains(roomId);
} else {
return false;
}
}
/**
* Tells whether the client should encrypt messages only for the verified devices
* in this room.
* The default value is false.
* This function must be called in the getEncryptingThreadHandler() thread.
*
* @param roomId the room id
* @param callback the asynchronous callback
*/
public void isRoomBlacklistUnverifiedDevices(final String roomId, final ApiCallback<Boolean> callback) {
getEncryptingThreadHandler().post(new Runnable() {
@Override
public void run() {
final boolean status = isRoomBlacklistUnverifiedDevices(roomId);
getUIHandler().post(new Runnable() {
@Override
public void run() {
if (null != callback) {
callback.onSuccess(status);
}
}
});
}
});
}
/**
* Manages the room black-listing for unverified devices.
*
* @param roomId the room id
* @param add true to add the room id to the list, false to remove it.
* @param callback the asynchronous callback
*/
private void setRoomBlacklistUnverifiedDevices(final String roomId, final boolean add, final ApiCallback<Void> callback) {
final Room room = mSession.getDataHandler().getRoom(roomId);
// sanity check
if (null == room) {
getUIHandler().post(new Runnable() {
@Override
public void run() {
callback.onSuccess(null);
}
});
return;
}
getEncryptingThreadHandler().post(new Runnable() {
@Override
public void run() {
List<String> roomIds = mCryptoStore.getRoomsListBlacklistUnverifiedDevices();
if (add) {
if (!roomIds.contains(roomId)) {
roomIds.add(roomId);
}
} else {
roomIds.remove(roomId);
}
mCryptoStore.setRoomsListBlacklistUnverifiedDevices(roomIds);
getUIHandler().post(new Runnable() {
@Override
public void run() {
if (null != callback) {
callback.onSuccess(null);
}
}
});
}
});
}
/**
* Add this room to the ones which don't encrypt messages to unverified devices.
*
* @param roomId the room id
* @param callback the asynchronous callback
*/
public void setRoomBlacklistUnverifiedDevices(final String roomId, final ApiCallback<Void> callback) {
setRoomBlacklistUnverifiedDevices(roomId, true, callback);
}
/**
* Remove this room to the ones which don't encrypt messages to unverified devices.
*
* @param roomId the room id
* @param callback the asynchronous callback
*/
public void setRoomUnblacklistUnverifiedDevices(final String roomId, final ApiCallback<Void> callback) {
setRoomBlacklistUnverifiedDevices(roomId, false, callback);
}
}