/*
* 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.text.TextUtils;
import org.matrix.androidsdk.MXSession;
import org.matrix.androidsdk.crypto.data.MXDeviceInfo;
import org.matrix.androidsdk.crypto.data.MXUsersDevicesMap;
import org.matrix.androidsdk.data.cryptostore.IMXCryptoStore;
import org.matrix.androidsdk.rest.callback.ApiCallback;
import org.matrix.androidsdk.rest.model.MatrixError;
import org.matrix.androidsdk.rest.model.crypto.KeysQueryResponse;
import org.matrix.androidsdk.util.Log;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
public class MXDeviceList {
private static final String LOG_TAG = "MXDeviceList";
// keys in progress
private final HashSet<String> mUserKeyDownloadsInProgress = new HashSet<>();
// pending request
private final HashSet<String> mPendingUsersWithNewDevices = new HashSet<>();
// HS not ready for retry
private final HashSet<String> mNotReadyToRetryHS = new HashSet<>();
// download keys queue
class DownloadKeysPromise {
// list of remain pending device keys
final List<String> mPendingUserIdsList;
// the unfiltered user ids list
final List<String> mUserIdsList;
// the request callback
final ApiCallback<MXUsersDevicesMap<MXDeviceInfo>> mCallback;
/**
* Creator
*
* @param userIds the user ids list
* @param callback the asynchronous callback
*/
DownloadKeysPromise(List<String> userIds, ApiCallback<MXUsersDevicesMap<MXDeviceInfo>> callback) {
mPendingUserIdsList = new ArrayList<>(userIds);
mUserIdsList = new ArrayList<>(userIds);
mCallback = callback;
}
}
// pending queues list
private final List<DownloadKeysPromise> mDownloadKeysQueues = new ArrayList<>();
private final MXCrypto mxCrypto;
private final MXSession mxSession;
// tells if there is a download keys request in progress
private boolean mIsDownloadingKeys = false;
/**
* Constructor
*
* @param crypto the crypto session
*/
public MXDeviceList(MXSession session, MXCrypto crypto) {
mxSession = session;
mxCrypto = crypto;
}
/**
* Tells if the keys downloads for an user id is either in progress or pending
*
* @param userId the user id
* @return true if teh keys download is either in progress or pending
*/
private boolean isKeysDownloading(String userId) {
if (null != userId) {
return mUserKeyDownloadsInProgress.contains(userId) || mPendingUsersWithNewDevices.contains(userId);
}
return false;
}
/**
* Add new pending users with new devices
*
* @param userIds the user ids list
*/
public void addPendingUsersWithNewDevices(List<String> userIds) {
synchronized (mPendingUsersWithNewDevices) {
mPendingUsersWithNewDevices.addAll(userIds);
}
}
/**
* Provides new pending users with new devices
*
* @return the the user ids list
*/
private List<String> getPendingUsersWithNewDevices() {
final List<String> users;
synchronized (mPendingUsersWithNewDevices) {
users = new ArrayList<>(mPendingUsersWithNewDevices);
// We've kicked off requests to these users: remove their
// pending flag for now.
mPendingUsersWithNewDevices.clear();
}
return users;
}
/**
* Tells if the key downloads should be tried
*
* @param userId the userId
* @return true if the keys download can be retrieved
*/
private boolean canRetryKeysDownload(String userId) {
boolean res = false;
if (!TextUtils.isEmpty(userId) && userId.contains(":")) {
try {
synchronized (mNotReadyToRetryHS) {
res = !mNotReadyToRetryHS.contains(userId.substring(userId.lastIndexOf(":") + 1));
}
} catch (Exception e) {
Log.e(LOG_TAG, "## canRetryKeysDownload() failed : " + e.getMessage());
}
}
return res;
}
/**
* Add a download keys promise
*
* @param userIds the user ids list
* @param callback the asynchronous callback
* @return the filtered user ids list i.e the one which require a remote request
*/
private List<String> addDownloadKeysPromise(List<String> userIds, ApiCallback<MXUsersDevicesMap<MXDeviceInfo>> callback) {
if (null != userIds) {
List<String> filteredUserIds = new ArrayList<>(userIds);
synchronized (mUserKeyDownloadsInProgress) {
filteredUserIds.removeAll(mUserKeyDownloadsInProgress);
mUserKeyDownloadsInProgress.addAll(userIds);
}
synchronized (mPendingUsersWithNewDevices) {
mPendingUsersWithNewDevices.removeAll(userIds);
}
mDownloadKeysQueues.add(new DownloadKeysPromise(userIds, callback));
return filteredUserIds;
} else {
return null;
}
}
/**
* Clear the unavailable server lists
*/
private void clearUnavailableServersList() {
synchronized (mNotReadyToRetryHS) {
mNotReadyToRetryHS.clear();
}
}
/**
* Invalidate the user device list
*
* @param userIds the user ids list
*/
public void invalidateUserDeviceList(List<String> userIds) {
if ((null != userIds) && (0 != userIds.size())) {
Log.d(LOG_TAG, "## invalidateUserDeviceList() : " + userIds);
addPendingUsersWithNewDevices(userIds);
clearUnavailableServersList();
}
}
/**
* The keys download failed
*
* @param userIds the user ids list
*/
private void onKeysDownloadFailed(final List<String> userIds) {
if (null != userIds) {
synchronized (mUserKeyDownloadsInProgress) {
mUserKeyDownloadsInProgress.removeAll(userIds);
}
synchronized (mPendingUsersWithNewDevices) {
mPendingUsersWithNewDevices.addAll(userIds);
}
}
mIsDownloadingKeys = false;
}
/**
* The keys download succeeded.
*
* @param userIds the userIds list
* @param failures the failure map.
*/
private void onKeysDownloadSucceed(List<String> userIds, Map<String, Map<String, Object>> failures) {
if (null != failures) {
Set<String> keys = failures.keySet();
for (String k : keys) {
Map<String, Object> value = failures.get(k);
if (value.containsKey("status")) {
Object statusCodeAsVoid = value.get("status");
int statusCode = 0;
if (statusCodeAsVoid instanceof Double) {
statusCode = ((Double) statusCodeAsVoid).intValue();
} else if (statusCodeAsVoid instanceof Integer) {
statusCode = ((Integer) statusCodeAsVoid).intValue();
}
if (statusCode == 503) {
synchronized (mNotReadyToRetryHS) {
mNotReadyToRetryHS.add(k);
}
}
}
}
}
if (null != userIds) {
if (mDownloadKeysQueues.size() > 0) {
ArrayList<DownloadKeysPromise> promisesToRemove = new ArrayList<>();
for (DownloadKeysPromise promise : mDownloadKeysQueues) {
promise.mPendingUserIdsList.removeAll(userIds);
if (promise.mPendingUserIdsList.size() == 0) {
// private members
final MXUsersDevicesMap<MXDeviceInfo> usersDevicesInfoMap = new MXUsersDevicesMap<>();
for (String userId : promise.mUserIdsList) {
Map<String, MXDeviceInfo> devices = mxCrypto.getCryptoStore().getUserDevices(userId);
if (null == devices) {
synchronized (mPendingUsersWithNewDevices) {
if (canRetryKeysDownload(userId)) {
mPendingUsersWithNewDevices.add(userId);
Log.e(LOG_TAG, "failed to retry the devices of " + userId + " : retry later");
} else {
Log.e(LOG_TAG, "failed to retry the devices of " + userId + " : the HS is not available");
}
}
} else {
// And the response result
usersDevicesInfoMap.setObjects(devices, userId);
}
}
if (!mxCrypto.hasBeenReleased()) {
final ApiCallback<MXUsersDevicesMap<MXDeviceInfo>> callback = promise.mCallback;
if (null != callback) {
mxCrypto.getUIHandler().post(new Runnable() {
@Override
public void run() {
callback.onSuccess(usersDevicesInfoMap);
}
});
}
}
promisesToRemove.add(promise);
}
}
mDownloadKeysQueues.removeAll(promisesToRemove);
}
mUserKeyDownloadsInProgress.removeAll(userIds);
}
mIsDownloadingKeys = false;
}
/**
* Download the device keys for a list of users and stores the keys in the MXStore.
* It must be called in getEncryptingThreadHandler() thread.
* The callback is called in the UI thread.
*
* @param userIds The users to fetch.
* @param forceDownload Always download the keys even if cached.
* @param callback the asynchronous callback
*/
public void downloadKeys(List<String> userIds, boolean forceDownload, final ApiCallback<MXUsersDevicesMap<MXDeviceInfo>> callback) {
Log.d(LOG_TAG, "## downloadKeys() : forceDownload " + forceDownload + " : " + userIds);
// Map from userid -> deviceid -> DeviceInfo
final MXUsersDevicesMap<MXDeviceInfo> stored = new MXUsersDevicesMap<>();
// List of user ids we need to download keys for
final ArrayList<String> downloadUsers;
if (forceDownload) {
downloadUsers = (null == userIds) ? new ArrayList<String>() : new ArrayList<>(userIds);
} else {
downloadUsers = new ArrayList<>();
if (null != userIds) {
IMXCryptoStore store = mxCrypto.getCryptoStore();
for (String userId : userIds) {
Map<String, MXDeviceInfo> devices = store.getUserDevices(userId);
if (null == devices) {
downloadUsers.add(userId);
} else {
// the keys download won't be triggered twice
// but the callback requires the dedicated keys
if (isKeysDownloading(userId)) {
downloadUsers.add(userId);
} else {
stored.setObjects(devices, userId);
}
}
}
}
}
if (0 == downloadUsers.size()) {
Log.d(LOG_TAG, "## downloadKeys() : no new user device");
if (null != callback) {
mxCrypto.getUIHandler().post(new Runnable() {
@Override
public void run() {
callback.onSuccess(stored);
}
});
}
} else {
Log.d(LOG_TAG, "## downloadKeys() : starts");
final long t0 = System.currentTimeMillis();
doKeyDownloadForUsers(downloadUsers, new ApiCallback<MXUsersDevicesMap<MXDeviceInfo>>() {
public void onSuccess(MXUsersDevicesMap<MXDeviceInfo> usersDevicesInfoMap) {
Log.d(LOG_TAG, "## downloadKeys() : doKeyDownloadForUsers succeeds after " + (System.currentTimeMillis() - t0) + " ms");
usersDevicesInfoMap.addEntriesFromMap(stored);
if (null != callback) {
callback.onSuccess(usersDevicesInfoMap);
}
}
@Override
public void onNetworkError(Exception e) {
Log.e(LOG_TAG, "## downloadKeys() : doKeyDownloadForUsers onNetworkError " + e.getMessage());
if (null != callback) {
callback.onNetworkError(e);
}
}
@Override
public void onMatrixError(MatrixError e) {
Log.e(LOG_TAG, "## downloadKeys() : doKeyDownloadForUsers onMatrixError " + e.getLocalizedMessage());
if (null != callback) {
callback.onMatrixError(e);
}
}
@Override
public void onUnexpectedError(Exception e) {
Log.e(LOG_TAG, "## downloadKeys() : doKeyDownloadForUsers onUnexpectedError " + e.getMessage());
if (null != callback) {
callback.onUnexpectedError(e);
}
}
});
}
}
/**
* Download the devices keys for a set of users.
* It must be called in getEncryptingThreadHandler() thread.
* The callback is called in the UI thread.
*
* @param downloadUsers the user ids list
* @param callback the asynchronous callback
*/
private void doKeyDownloadForUsers(final List<String> downloadUsers, final ApiCallback<MXUsersDevicesMap<MXDeviceInfo>> callback) {
Log.d(LOG_TAG, "## doKeyDownloadForUsers() : doKeyDownloadForUsers " + downloadUsers);
// get the user ids which did not already trigger a keys download
final List<String> filteredUsers = addDownloadKeysPromise(downloadUsers, callback);
// if there is no new keys request
if (0 == filteredUsers.size()) {
// trigger nothing
return;
}
// sanity check
if ((null == mxSession.getDataHandler()) || (null == mxSession.getDataHandler().getStore())) {
return;
}
mIsDownloadingKeys = true;
mxSession.getCryptoRestClient().downloadKeysForUsers(filteredUsers, mxSession.getDataHandler().getStore().getEventStreamToken(), new ApiCallback<KeysQueryResponse>() {
@Override
public void onSuccess(final KeysQueryResponse keysQueryResponse) {
mxCrypto.getEncryptingThreadHandler().post(new Runnable() {
@Override
public void run() {
Log.d(LOG_TAG, "## doKeyDownloadForUsers() : Got keys for " + filteredUsers.size() + " users");
MXDeviceInfo myDevice = mxCrypto.getMyDevice();
IMXCryptoStore cryptoStore = mxCrypto.getCryptoStore();
for (String userId : filteredUsers) {
Map<String, MXDeviceInfo> devices = keysQueryResponse.deviceKeys.get(userId);
Log.d(LOG_TAG, "## doKeyDownloadForUsers() : Got keys for " + userId + " : " + devices);
if (null != devices) {
HashMap<String, MXDeviceInfo> mutableDevices = new HashMap<>(devices);
ArrayList<String> deviceIds = new ArrayList<>(mutableDevices.keySet());
for (String deviceId : deviceIds) {
// the user has been logged out
if (null == cryptoStore) {
break;
}
// Get the potential previously store device keys for this device
MXDeviceInfo previouslyStoredDeviceKeys = cryptoStore.getUserDevice(deviceId, userId);
MXDeviceInfo deviceInfo = mutableDevices.get(deviceId);
// in some race conditions (like unit tests)
// the self device must be seen as verified
if (TextUtils.equals(deviceInfo.deviceId, myDevice.deviceId) &&
TextUtils.equals(userId, myDevice.userId)) {
deviceInfo.mVerified = MXDeviceInfo.DEVICE_VERIFICATION_VERIFIED;
}
// Validate received keys
if (!validateDeviceKeys(deviceInfo, userId, deviceId, previouslyStoredDeviceKeys)) {
// New device keys are not valid. Do not store them
mutableDevices.remove(deviceId);
if (null != previouslyStoredDeviceKeys) {
// But keep old validated ones if any
mutableDevices.put(deviceId, previouslyStoredDeviceKeys);
}
} else if (null != previouslyStoredDeviceKeys) {
// The verified status is not sync'ed with hs.
// This is a client side information, valid only for this client.
// So, transfer its previous value
mutableDevices.get(deviceId).mVerified = previouslyStoredDeviceKeys.mVerified;
}
}
// Update the store
// Note that devices which aren't in the response will be removed from the stores
cryptoStore.storeUserDevices(userId, mutableDevices);
}
}
onKeysDownloadSucceed(filteredUsers, keysQueryResponse.failures);
}
});
}
@Override
public void onNetworkError(Exception e) {
mxCrypto.getEncryptingThreadHandler().post(new Runnable() {
@Override
public void run() {
onKeysDownloadFailed(filteredUsers);
}
});
Log.e(LOG_TAG, "##doKeyDownloadForUsers() : onNetworkError " + e.getMessage());
if (null != callback) {
callback.onNetworkError(e);
}
}
@Override
public void onMatrixError(MatrixError e) {
Log.e(LOG_TAG, "##doKeyDownloadForUsers() : onMatrixError " + e.getMessage());
mxCrypto.getEncryptingThreadHandler().post(new Runnable() {
@Override
public void run() {
onKeysDownloadFailed(filteredUsers);
}
});
if (null != callback) {
callback.onMatrixError(e);
}
}
@Override
public void onUnexpectedError(Exception e) {
Log.e(LOG_TAG, "##doKeyDownloadForUsers() : onUnexpectedError " + e.getMessage());
mxCrypto.getEncryptingThreadHandler().post(new Runnable() {
@Override
public void run() {
onKeysDownloadFailed(filteredUsers);
}
});
if (null != callback) {
callback.onUnexpectedError(e);
}
}
});
}
/**
* Validate device keys.
* This method must called on getEncryptingThreadHandler() thread.
*
* @param deviceKeys the device keys to validate.
* @param userId the id of the user of the device.
* @param deviceId the id of the device.
* @param previouslyStoredDeviceKeys the device keys we received before for this device
* @return true if succeeds
*/
private boolean validateDeviceKeys(MXDeviceInfo deviceKeys, String userId, String deviceId, MXDeviceInfo previouslyStoredDeviceKeys) {
if (null == deviceKeys) {
Log.e(LOG_TAG, "## validateDeviceKeys() : deviceKeys is null from " + userId + ":" + deviceId);
return false;
}
if (null == deviceKeys.keys) {
Log.e(LOG_TAG, "## validateDeviceKeys() : deviceKeys.keys is null from " + userId + ":" + deviceId);
return false;
}
if (null == deviceKeys.signatures) {
Log.e(LOG_TAG, "## validateDeviceKeys() : deviceKeys.signatures is null from " + userId + ":" + deviceId);
return false;
}
// Check that the user_id and device_id in the received deviceKeys are correct
if (!TextUtils.equals(deviceKeys.userId, userId)) {
Log.e(LOG_TAG, "## validateDeviceKeys() : Mismatched user_id " + deviceKeys.userId + " from " + userId + ":" + deviceId);
return false;
}
if (!TextUtils.equals(deviceKeys.deviceId, deviceId)) {
Log.e(LOG_TAG, "## validateDeviceKeys() : Mismatched device_id " + deviceKeys.deviceId + " from " + userId + ":" + deviceId);
return false;
}
String signKeyId = "ed25519:" + deviceKeys.deviceId;
String signKey = deviceKeys.keys.get(signKeyId);
if (null == signKey) {
Log.e(LOG_TAG, "## validateDeviceKeys() : Device " + userId + ":" + deviceKeys.deviceId + " has no ed25519 key");
return false;
}
Map<String, String> signatureMap = deviceKeys.signatures.get(userId);
if (null == signatureMap) {
Log.e(LOG_TAG, "## validateDeviceKeys() : Device " + userId + ":" + deviceKeys.deviceId + " has no map for " + userId);
return false;
}
String signature = signatureMap.get(signKeyId);
if (null == signature) {
Log.e(LOG_TAG, "## validateDeviceKeys() : Device " + userId + ":" + deviceKeys.deviceId + " is not signed");
return false;
}
boolean isVerified = false;
String errorMessage = null;
try {
mxCrypto.getOlmDevice().verifySignature(signKey, deviceKeys.signalableJSONDictionary(), signature);
isVerified = true;
} catch (Exception e) {
errorMessage = e.getMessage();
}
if (!isVerified) {
Log.e(LOG_TAG, "## validateDeviceKeys() : Unable to verify signature on device " + userId + ":" + deviceKeys.deviceId + " with error " + errorMessage);
return false;
}
if (null != previouslyStoredDeviceKeys) {
if (!TextUtils.equals(previouslyStoredDeviceKeys.fingerprint(), signKey)) {
// This should only happen if the list has been MITMed; we are
// best off sticking with the original keys.
//
// Should we warn the user about it somehow?
Log.e(LOG_TAG, "## validateDeviceKeys() : WARNING:Ed25519 key for device " + userId + ":" + deviceKeys.deviceId + " has changed");
return false;
}
}
return true;
}
/**
* Start device queries for any users who sent us an m.new_device recently
* This method must be called on getEncryptingThreadHandler() thread.
*/
public void refreshOutdatedDeviceLists() {
final List<String> users = getPendingUsersWithNewDevices();
if (users.size() == 0) {
return;
}
if (mIsDownloadingKeys) {
// request already in progress - do nothing. (We will automatically
// make another request if there are more users with outdated
// device lists when the current request completes).
return;
}
doKeyDownloadForUsers(users, new ApiCallback<MXUsersDevicesMap<MXDeviceInfo>>() {
@Override
public void onSuccess(final MXUsersDevicesMap<MXDeviceInfo> response) {
mxCrypto.getEncryptingThreadHandler().post(new Runnable() {
@Override
public void run() {
Log.d(LOG_TAG, "## refreshOutdatedDeviceLists() : done");
}
});
}
private void onError(String error) {
Log.e(LOG_TAG, "## refreshOutdatedDeviceLists() : ERROR updating device keys for users " + users + " : " + error);
}
@Override
public void onNetworkError(final Exception e) {
onError(e.getMessage());
}
@Override
public void onMatrixError(final MatrixError e) {
onError(e.getMessage());
}
@Override
public void onUnexpectedError(final Exception e) {
onError(e.getMessage());
}
});
}
}