feat: support dehydrated devices

This commit is contained in:
Nicolas Werner 2022-08-22 14:20:51 +02:00
parent a536a7ea8b
commit dd1f61c29e
14 changed files with 597 additions and 91 deletions

View File

@ -43,11 +43,18 @@ class Encryption {
String? get fingerprintKey => olmManager.fingerprintKey;
String? get identityKey => olmManager.identityKey;
late KeyManager keyManager;
late OlmManager olmManager;
late KeyVerificationManager keyVerificationManager;
late CrossSigning crossSigning;
late SSSS ssss;
/// Returns the database used to store olm sessions and the olm account.
/// We don't want to store olm keys for dehydrated devices.
DatabaseApi? get olmDatabase =>
ourDeviceId == client.deviceID ? client.database : null;
late final KeyManager keyManager;
late final OlmManager olmManager;
late final KeyVerificationManager keyVerificationManager;
late final CrossSigning crossSigning;
late SSSS ssss; // some tests mock this, which is why it isn't final
late String ourDeviceId;
Encryption({
required this.client,
@ -61,9 +68,17 @@ class Encryption {
}
// initial login passes null to init a new olm account
Future<void> init(String? olmAccount) async {
await olmManager.init(olmAccount);
_backgroundTasksRunning = true;
Future<void> init(String? olmAccount,
{String? deviceId,
String? pickleKey,
bool isDehydratedDevice = false}) async {
ourDeviceId = deviceId ?? client.deviceID!;
await olmManager.init(
olmAccount: olmAccount,
deviceId: isDehydratedDevice ? deviceId : ourDeviceId,
pickleKey: pickleKey);
_backgroundTasksRunning = ourDeviceId ==
client.deviceID; // Don't run tasks for dehydrated devices
_backgroundTasks(); // start the background tasks
}

View File

@ -96,7 +96,7 @@ class KeyManager {
Map<String, String>? senderClaimedKeys,
bool uploaded = false,
Map<String, Map<String, int>>? allowedAtIndex,
}) {
}) async {
final senderClaimedKeys_ = senderClaimedKeys ?? <String, String>{};
final allowedAtIndex_ = allowedAtIndex ?? <String, Map<String, int>>{};
final userId = client.userID;
@ -111,7 +111,7 @@ class KeyManager {
final oldSession =
getInboundGroupSession(roomId, sessionId, senderKey, otherRooms: false);
if (content['algorithm'] != AlgorithmTypes.megolmV1AesSha2) {
return Future.value();
return;
}
late olm.InboundGroupSession inboundGroupSession;
try {
@ -150,14 +150,14 @@ class KeyManager {
} else {
// we are gonna keep our old session
newSession.dispose();
return Future.value();
return;
}
final roomInboundGroupSessions =
_inboundGroupSessions[roomId] ??= <String, SessionKey>{};
roomInboundGroupSessions[sessionId] = newSession;
if (!client.isLogged() || client.encryption == null) {
return Future.value();
return;
}
final storeFuture = client.database
?.storeInboundGroupSession(
@ -170,12 +170,13 @@ class KeyManager {
senderKey,
json.encode(senderClaimedKeys_),
)
.then((_) {
.then((_) async {
if (!client.isLogged() || client.encryption == null) {
return;
}
if (uploaded) {
client.database?.markInboundGroupSessionAsUploaded(roomId, sessionId);
await client.database
?.markInboundGroupSessionAsUploaded(roomId, sessionId);
} else {
_haveKeysToUpload = true;
}

View File

@ -27,12 +27,14 @@ import 'package:matrix/encryption/encryption.dart';
import 'package:matrix/encryption/utils/json_signature_check_extension.dart';
import 'package:matrix/encryption/utils/olm_session.dart';
import 'package:matrix/matrix.dart';
import 'package:matrix/msc_extensions/msc_3814_dehydrated_devices/api.dart';
import 'package:matrix/src/utils/run_in_root.dart';
class OlmManager {
final Encryption encryption;
Client get client => encryption.client;
olm.Account? _olmAccount;
String? ourDeviceId;
/// Returns the base64 encoded keys to store them in a store.
/// This String should **never** leave the device!
@ -43,6 +45,9 @@ class OlmManager {
String? get identityKey =>
enabled ? json.decode(_olmAccount!.identity_keys())['curve25519'] : null;
String? pickleOlmAccountWithKey(String key) =>
enabled ? _olmAccount!.pickle(key) : null;
bool get enabled => _olmAccount != null;
OlmManager(this.encryption);
@ -52,13 +57,21 @@ class OlmManager {
final Map<String, List<OlmSession>> _olmSessions = {};
// NOTE(Nico): On initial login we pass null to create a new account
Future<void> init(String? olmAccount) async {
Future<void> init(
{String? olmAccount,
required String? deviceId,
String? pickleKey}) async {
ourDeviceId = deviceId;
if (olmAccount == null) {
try {
await olm.init();
_olmAccount = olm.Account();
_olmAccount!.create();
if (!await uploadKeys(uploadDeviceKeys: true, updateDatabase: false)) {
if (!await uploadKeys(
uploadDeviceKeys: true,
updateDatabase: false,
// dehydrated devices don't have a device id when created, so skip upload in that case.
skipAllUploads: deviceId == null)) {
throw ('Upload key failed');
}
} catch (_) {
@ -70,7 +83,7 @@ class OlmManager {
try {
await olm.init();
_olmAccount = olm.Account();
_olmAccount!.unpickle(client.userID!, olmAccount);
_olmAccount!.unpickle(pickleKey ?? client.userID!, olmAccount);
} catch (_) {
_olmAccount?.free();
_olmAccount = null;
@ -97,8 +110,7 @@ class OlmManager {
if (!payload['signatures'].containsKey(client.userID)) {
payload['signatures'][client.userID] = <String, dynamic>{};
}
payload['signatures'][client.userID]['ed25519:${client.deviceID}'] =
signature;
payload['signatures'][client.userID]['ed25519:$ourDeviceId'] = signature;
if (unsigned != null) {
payload['unsigned'] = unsigned;
}
@ -118,6 +130,7 @@ class OlmManager {
int? oldKeyCount = 0,
bool updateDatabase = true,
bool? unusedFallbackKey = false,
bool skipAllUploads = false,
}) async {
final olmAccount = _olmAccount;
if (olmAccount == null) {
@ -130,7 +143,6 @@ class OlmManager {
_uploadKeysLock = true;
try {
final signedOneTimeKeys = <String, dynamic>{};
int? uploadedOneTimeKeysCount;
if (oldKeyCount != null) {
// check if we have OTKs that still need uploading. If we do, we don't try to generate new ones,
@ -150,24 +162,61 @@ class OlmManager {
olmAccount.generate_one_time_keys(oneTimeKeysCount);
}
uploadedOneTimeKeysCount = oneTimeKeysCount + oldOTKsNeedingUpload;
final Map<String, dynamic> oneTimeKeys =
json.decode(olmAccount.one_time_keys());
}
// now sign all the one-time keys
for (final entry in oneTimeKeys['curve25519'].entries) {
final key = entry.key;
if (encryption.isMinOlmVersion(3, 2, 7) && unusedFallbackKey == false) {
// we don't have an unused fallback key uploaded....so let's change that!
olmAccount.generate_fallback_key();
}
// we save the generated OTKs into the database.
// in case the app gets killed during upload or the upload fails due to bad network
// we can still re-try later
if (updateDatabase) {
await encryption.olmDatabase?.updateClientKeys(pickledOlmAccount!);
}
if (skipAllUploads) {
_uploadKeysLock = false;
return true;
}
// and now generate the payload to upload
var deviceKeys = <String, dynamic>{
'user_id': client.userID,
'device_id': ourDeviceId,
'algorithms': [
AlgorithmTypes.olmV1Curve25519AesSha2,
AlgorithmTypes.megolmV1AesSha2
],
'keys': <String, dynamic>{},
};
if (uploadDeviceKeys) {
final Map<String, dynamic> keys =
json.decode(olmAccount.identity_keys());
for (final entry in keys.entries) {
final algorithm = entry.key;
final value = entry.value;
signedOneTimeKeys['signed_curve25519:$key'] = signJson({
'key': value,
});
deviceKeys['keys']['$algorithm:$ourDeviceId'] = value;
}
deviceKeys = signJson(deviceKeys);
}
final signedOneTimeKeys = <String, dynamic>{};
// now sign all the one-time keys
for (final entry
in json.decode(olmAccount.one_time_keys())['curve25519'].entries) {
final key = entry.key;
final value = entry.value;
signedOneTimeKeys['signed_curve25519:$key'] = signJson({
'key': value,
});
}
final signedFallbackKeys = <String, dynamic>{};
if (encryption.isMinOlmVersion(3, 2, 0) && unusedFallbackKey == false) {
// we don't have an unused fallback key uploaded....so let's change that!
olmAccount.generate_fallback_key();
final fallbackKey = json.decode(olmAccount.fallback_key());
if (encryption.isMinOlmVersion(3, 2, 7)) {
final fallbackKey = json.decode(olmAccount.unpublished_fallback_key());
// now sign all the fallback keys
for (final entry in fallbackKey['curve25519'].entries) {
final key = entry.key;
@ -179,48 +228,32 @@ class OlmManager {
}
}
// and now generate the payload to upload
final keysContent = <String, dynamic>{
if (uploadDeviceKeys)
'device_keys': {
'user_id': client.userID,
'device_id': client.deviceID,
'algorithms': [
AlgorithmTypes.olmV1Curve25519AesSha2,
AlgorithmTypes.megolmV1AesSha2
],
'keys': <String, dynamic>{},
},
};
if (uploadDeviceKeys) {
final Map<String, dynamic> keys =
json.decode(olmAccount.identity_keys());
for (final entry in keys.entries) {
final algorithm = entry.key;
final value = entry.value;
keysContent['device_keys']['keys']['$algorithm:${client.deviceID}'] =
value;
}
keysContent['device_keys'] =
signJson(keysContent['device_keys'] as Map<String, dynamic>);
if (signedFallbackKeys.isEmpty &&
signedOneTimeKeys.isEmpty &&
!uploadDeviceKeys) {
_uploadKeysLock = false;
return true;
}
// we save the generated OTKs into the database.
// in case the app gets killed during upload or the upload fails due to bad network
// we can still re-try later
if (updateDatabase) {
await client.database?.updateClientKeys(pickledOlmAccount!);
}
// Workaround: Make sure we stop if we got logged out in the meantime.
if (!client.isLogged()) return true;
final currentUpload =
this.currentUpload = CancelableOperation.fromFuture(client.uploadKeys(
deviceKeys: uploadDeviceKeys
? MatrixDeviceKeys.fromJson(keysContent['device_keys'])
: null,
oneTimeKeys: signedOneTimeKeys,
fallbackKeys: signedFallbackKeys,
));
final currentUpload = this.currentUpload =
CancelableOperation.fromFuture(ourDeviceId == client.deviceID
? client.uploadKeys(
deviceKeys: uploadDeviceKeys
? MatrixDeviceKeys.fromJson(deviceKeys)
: null,
oneTimeKeys: signedOneTimeKeys,
fallbackKeys: signedFallbackKeys,
)
: client.uploadKeysForDevice(
ourDeviceId!,
deviceKeys: uploadDeviceKeys
? MatrixDeviceKeys.fromJson(deviceKeys)
: null,
oneTimeKeys: signedOneTimeKeys,
fallbackKeys: signedFallbackKeys,
));
final response = await currentUpload.valueOrCancellation();
if (response == null) {
_uploadKeysLock = false;
@ -230,7 +263,7 @@ class OlmManager {
// mark the OTKs as published and save that to datbase
olmAccount.mark_keys_as_published();
if (updateDatabase) {
await client.database?.updateClientKeys(pickledOlmAccount!);
await encryption.olmDatabase?.updateClientKeys(pickledOlmAccount!);
}
return (uploadedOneTimeKeysCount != null &&
response['signed_curve25519'] == uploadedOneTimeKeysCount) ||
@ -262,7 +295,7 @@ class OlmManager {
// fixup accidental too many uploads. We delete only one of them so that the server has time to update the counts and because we will get rate limited anyway.
if (keyCount > _olmAccount!.max_number_of_one_time_keys()) {
final requestingKeysFrom = {
client.userID!: {client.deviceID!: 'signed_curve25519'}
client.userID!: {ourDeviceId!: 'signed_curve25519'}
};
await client.claimKeys(requestingKeysFrom, timeout: 10000);
}
@ -294,7 +327,7 @@ class OlmManager {
// update an existing session
_olmSessions[session.identityKey]![ix] = session;
}
await client.database?.storeOlmSession(
await encryption.olmDatabase?.storeOlmSession(
session.identityKey,
session.sessionId!,
session.pickledSession!,
@ -332,7 +365,7 @@ class OlmManager {
}
if (device != null) {
device.lastActive = DateTime.now();
await client.database?.setLastActiveUserDeviceKey(
await encryption.olmDatabase?.setLastActiveUserDeviceKey(
device.lastActive.millisecondsSinceEpoch,
device.userId,
device.deviceId!);
@ -373,7 +406,7 @@ class OlmManager {
try {
newSession.create_inbound_from(_olmAccount!, senderKey, body);
_olmAccount!.remove_one_time_keys(newSession);
await client.database?.updateClientKeys(pickledOlmAccount!);
await encryption.olmDatabase?.updateClientKeys(pickledOlmAccount!);
plaintext = newSession.decrypt(type, body);
await runInRoot(() => storeOlmSession(OlmSession(
key: client.userID!,
@ -410,13 +443,13 @@ class OlmManager {
Future<List<OlmSession>> getOlmSessionsFromDatabase(String senderKey) async {
final olmSessions =
await client.database?.getOlmSessions(senderKey, client.userID!);
await encryption.olmDatabase?.getOlmSessions(senderKey, client.userID!);
return olmSessions?.where((sess) => sess.isValid).toList() ?? [];
}
Future<void> getOlmSessionsForDevicesFromDatabase(
List<String> senderKeys) async {
final rows = await client.database?.getOlmSessionsForDevices(
final rows = await encryption.olmDatabase?.getOlmSessionsForDevices(
senderKeys,
client.userID!,
);
@ -576,9 +609,9 @@ class OlmManager {
};
final encryptResult = sess.first.session!.encrypt(json.encode(fullPayload));
await storeOlmSession(sess.first);
if (client.database != null) {
if (encryption.olmDatabase != null) {
await runInRoot(
() async => client.database?.setLastSentMessageUserDeviceKey(
() async => encryption.olmDatabase?.setLastSentMessageUserDeviceKey(
json.encode({
'type': type,
'content': payload,
@ -604,7 +637,7 @@ class OlmManager {
Map<String, dynamic> payload) async {
final data = <String, Map<String, Map<String, dynamic>>>{};
// first check if any of our sessions we want to encrypt for are in the database
if (client.database != null) {
if (encryption.olmDatabase != null) {
await getOlmSessionsForDevicesFromDatabase(
deviceKeys.map((d) => d.curve25519Key!).toList());
}
@ -633,7 +666,7 @@ class OlmManager {
// We receive dan encrypted m.dummy. This means that the other end was not able to
// decrypt our last message. So, we re-send it.
final encryptedContent = event.encryptedContent;
if (encryptedContent == null || client.database == null) {
if (encryptedContent == null || encryption.olmDatabase == null) {
return;
}
final device = client.getUserDeviceKeysByCurve25519Key(
@ -643,7 +676,7 @@ class OlmManager {
}
Logs().v(
'[OlmManager] Device ${device.userId}:${device.deviceId} generated a new olm session, replaying last sent message...');
final lastSentMessageRes = await client.database
final lastSentMessageRes = await encryption.olmDatabase
?.getLastSentMessageUserDeviceKey(device.userId, device.deviceId!);
if (lastSentMessageRes == null ||
lastSentMessageRes.isEmpty ||

View File

@ -27,6 +27,7 @@ import 'package:matrix/encryption/key_manager.dart';
import 'package:matrix/encryption/ssss.dart';
import 'package:matrix/encryption/utils/base64_unpadded.dart';
import 'package:matrix/matrix.dart';
import 'package:matrix/msc_extensions/msc_3814_dehydrated_devices/msc_3814_dehydrated_devices.dart';
enum BootstrapState {
/// Is loading.
@ -338,13 +339,14 @@ class Bootstrap {
state = BootstrapState.askSetupCrossSigning;
}
void wipeCrossSigning(bool wipe) {
Future<void> wipeCrossSigning(bool wipe) async {
if (state != BootstrapState.askWipeCrossSigning) {
throw BootstrapBadStateException();
}
if (wipe) {
state = BootstrapState.askSetupCrossSigning;
} else {
await client.dehydratedDeviceSetup(newSsssKey!);
checkOnlineKeyBackup();
}
}
@ -357,6 +359,7 @@ class Bootstrap {
throw BootstrapBadStateException();
}
if (!setupMasterKey && !setupSelfSigningKey && !setupUserSigningKey) {
await client.dehydratedDeviceSetup(newSsssKey!);
checkOnlineKeyBackup();
return;
}
@ -526,6 +529,7 @@ class Bootstrap {
return;
}
await client.dehydratedDeviceSetup(newSsssKey!);
checkOnlineKeyBackup();
}

View File

@ -16,7 +16,6 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import 'package:matrix_api_lite/src/utils/filter_map_extension.dart';
import 'package:olm/olm.dart' as olm;
import 'package:matrix/encryption/utils/stored_inbound_group_session.dart';

View File

@ -0,0 +1,94 @@
/* MIT License
*
* Copyright (C) 2019, 2020, 2021, 2022 Famedly GmbH
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import 'package:matrix_api_lite/matrix_api_lite.dart';
import 'package:matrix/msc_extensions/msc_3814_dehydrated_devices/model/dehydrated_device.dart';
import 'package:matrix/msc_extensions/msc_3814_dehydrated_devices/model/dehydrated_device_events.dart';
/// Endpoints related to MSC3814, dehydrated devices v2 aka shrivelled sessions
/// https://github.com/matrix-org/matrix-spec-proposals/pull/3814
extension DehydratedDeviceMatrixApi on MatrixApi {
/// Publishes end-to-end encryption keys for the specified device.
/// https://github.com/matrix-org/matrix-spec-proposals/pull/3814
Future<Map<String, int>> uploadKeysForDevice(String device,
{MatrixDeviceKeys? deviceKeys,
Map<String, dynamic>? oneTimeKeys,
Map<String, dynamic>? fallbackKeys}) async {
final response = await request(
RequestType.POST,
'/client/v3/keys/upload/${Uri.encodeComponent(device)}',
data: {
if (deviceKeys != null) 'device_keys': deviceKeys.toJson(),
if (oneTimeKeys != null) 'one_time_keys': oneTimeKeys,
if (fallbackKeys != null) ...{
'fallback_keys': fallbackKeys,
'org.matrix.msc2732.fallback_keys': fallbackKeys,
},
},
);
return Map<String, int>.from(response['one_time_key_counts']);
}
/// uploads a dehydrated device.
/// https://github.com/matrix-org/matrix-spec-proposals/pull/3814
Future<String> uploadDehydratedDevice(
{String? initialDeviceDisplayName,
Map<String, dynamic>? deviceData}) async {
final response = await request(
RequestType.PUT,
'/client/unstable/org.matrix.msc3814.v1/dehydrated_device',
data: {
if (initialDeviceDisplayName != null)
'initial_device_display_name': initialDeviceDisplayName,
if (deviceData != null) 'device_data': deviceData,
},
);
return response['device_id'] as String;
}
/// fetch a dehydrated device.
/// https://github.com/matrix-org/matrix-spec-proposals/pull/3814
Future<DehydratedDevice> getDehydratedDevice() async {
final response = await request(
RequestType.GET,
'/client/unstable/org.matrix.msc3814.v1/dehydrated_device',
);
return DehydratedDevice.fromJson(response);
}
/// fetch events sent to a dehydrated device.
/// https://github.com/matrix-org/matrix-spec-proposals/pull/3814
Future<DehydratedDeviceEvents> getDehydratedDeviceEvents(String deviceId,
{String? from, int limit = 100}) async {
final response = await request(
RequestType.GET,
'/client/unstable/org.matrix.msc3814.v1/dehydrated_device/$deviceId/events',
query: {
if (from != null) 'from': from,
'limit': limit.toString(),
},
);
return DehydratedDeviceEvents.fromJson(response);
}
}

View File

@ -0,0 +1,45 @@
/* MIT License
*
* Copyright (C) 2019, 2020, 2021, 2022 Famedly GmbH
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import 'package:matrix_api_lite/matrix_api_lite.dart';
class DehydratedDevice {
String deviceId;
Map<String, dynamic>? deviceData;
DehydratedDevice({
required this.deviceId,
this.deviceData,
});
DehydratedDevice.fromJson(Map<String, dynamic> json)
: deviceId = json['device_id'] as String,
deviceData = (json['device_data'] as Map<String, dynamic>?)?.copy();
Map<String, dynamic> toJson() {
return {
'device_id': deviceId,
if (deviceData != null) 'device_data': deviceData,
};
}
}

View File

@ -0,0 +1,48 @@
/* MIT License
*
* Copyright (C) 2019, 2020, 2021, 2022 Famedly GmbH
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import 'package:matrix/matrix.dart';
class DehydratedDeviceEvents {
String? nextBatch;
List<ToDeviceEvent>? events;
DehydratedDeviceEvents({
this.nextBatch,
this.events,
});
DehydratedDeviceEvents.fromJson(Map<String, dynamic> json)
: nextBatch = json['next_batch'] as String?,
events = json
.tryGetList<Map<String, dynamic>>('events')
?.map((i) => ToDeviceEvent.fromJson(i))
.toList();
Map<String, dynamic> toJson() {
return {
if (nextBatch != null) 'next_batch': nextBatch,
if (events != null) 'events': events,
};
}
}

View File

@ -0,0 +1,176 @@
library msc_3814_dehydrated_devices;
import 'dart:convert';
import 'package:matrix/encryption.dart';
import 'package:matrix/matrix.dart';
import 'package:matrix/msc_extensions/msc_3814_dehydrated_devices/api.dart';
import 'package:matrix/msc_extensions/msc_3814_dehydrated_devices/model/dehydrated_device.dart';
import 'package:matrix/msc_extensions/msc_3814_dehydrated_devices/model/dehydrated_device_events.dart';
import 'package:matrix/src/utils/crypto/crypto.dart' as uc;
extension DehydratedDeviceHandler on Client {
static const Set<String> _oldDehydratedDeviceAlgorithms = {
'com.famedly.dehydrated_device.raw_olm_account',
};
static const String _dehydratedDeviceAlgorithm =
'com.famedly.dehydrated_device.raw_olm_account.v2';
static const String _ssssSecretNameForDehydratedDevice = 'org.matrix.msc3814';
/// Restores the dehydrated device account and/or creates a new one, fetches the events and as such makes encrypted messages available while we were offline.
/// Usually it only makes sense to call this when you just entered the SSSS passphrase or recovery key successfully.
Future<void> dehydratedDeviceSetup(OpenSSSS secureStorage) async {
try {
// dehydrated devices need to be cross-signed
if (!enableDehydratedDevices ||
!encryptionEnabled ||
this.encryption?.crossSigning.enabled != true) {
return;
}
DehydratedDevice? device;
try {
device = await getDehydratedDevice();
} on MatrixException catch (e) {
if (e.response?.statusCode == 400) {
Logs().i('Dehydrated devices unsupported, skipping.');
return;
}
// No device, so we just create a new device.
await _uploadNewDevice(secureStorage);
return;
}
// Just throw away the old device if it is using an old algoritm. In the future we could try to still use it and then migrate it, but currently that is not worth the effort
if (_oldDehydratedDeviceAlgorithms
.contains(device.deviceData?.tryGet<String>('algorithm'))) {
await _uploadNewDevice(secureStorage);
return;
}
// Only handle devices we understand
// In the future we might want to migrate to a newer format here
if (device.deviceData?.tryGet<String>('algorithm') !=
_dehydratedDeviceAlgorithm) return;
// Verify that the device is cross-signed
final dehydratedDeviceIdentity =
userDeviceKeys[userID]!.deviceKeys[device.deviceId];
if (dehydratedDeviceIdentity == null ||
!dehydratedDeviceIdentity.hasValidSignatureChain()) {
Logs().w(
'Dehydrated device ${device.deviceId} is unknown or unverified, replacing it');
await _uploadNewDevice(secureStorage);
return;
}
final pickleDeviceKey =
await secureStorage.getStored(_ssssSecretNameForDehydratedDevice);
final pickledDevice = device.deviceData?.tryGet<String>('device');
if (pickledDevice == null) {
Logs()
.w('Dehydrated device ${device.deviceId} is invalid, replacing it');
await _uploadNewDevice(secureStorage);
return;
}
// Use a separate encryption object for the dehydrated device.
// We need to be careful to not use the client.deviceId here and such.
final encryption = Encryption(client: this);
try {
await encryption.init(pickledDevice,
deviceId: device.deviceId,
pickleKey: pickleDeviceKey,
isDehydratedDevice: true);
if (dehydratedDeviceIdentity.curve25519Key != encryption.identityKey ||
dehydratedDeviceIdentity.ed25519Key != encryption.fingerprintKey) {
Logs()
.w('Invalid dehydrated device ${device.deviceId}, replacing it');
await encryption.dispose();
await _uploadNewDevice(secureStorage);
return;
}
// Fetch the to_device messages sent to the picked device and handle them 1:1.
DehydratedDeviceEvents? events;
do {
events = await getDehydratedDeviceEvents(device.deviceId,
from: events?.nextBatch);
for (final e in events.events ?? []) {
// We are only interested in roomkeys, which ALWAYS need to be encrypted.
if (e.type == EventTypes.Encrypted) {
final decryptedEvent = await encryption.decryptToDeviceEvent(e);
if (decryptedEvent.type == EventTypes.RoomKey) {
await encryption.handleToDeviceEvent(decryptedEvent);
}
}
}
} while (events.events?.isNotEmpty == true);
await _uploadNewDevice(secureStorage);
} finally {
await encryption.dispose();
}
} catch (e) {
Logs().w('Exception while handling dehydrated devices: ${e.toString()}');
return;
}
}
Future<void> _uploadNewDevice(OpenSSSS secureStorage) async {
final encryption = Encryption(client: this);
try {
String? pickleDeviceKey;
try {
pickleDeviceKey =
await secureStorage.getStored(_ssssSecretNameForDehydratedDevice);
} catch (_) {
Logs().i('Dehydrated device key not found, creating new one.');
pickleDeviceKey = base64.encode(uc.secureRandomBytes(128));
await secureStorage.store(
_ssssSecretNameForDehydratedDevice, pickleDeviceKey);
}
// Generate a new olm account for the dehydrated device.
await encryption.init(null,
deviceId: null, isDehydratedDevice: true, pickleKey: pickleDeviceKey);
String device;
try {
device = await uploadDehydratedDevice(
initialDeviceDisplayName: 'Dehydrated Device',
deviceData: {
'algorithm': _dehydratedDeviceAlgorithm,
'device': encryption.olmManager
.pickleOlmAccountWithKey(pickleDeviceKey),
});
} on MatrixException catch (_) {
// dehydrated devices unsupported, do noting.
Logs().i('Dehydrated devices unsupported, skipping upload.');
await encryption.dispose();
return;
}
encryption.ourDeviceId = device;
encryption.olmManager.ourDeviceId = device;
await encryption.olmManager.uploadKeys(
uploadDeviceKeys: true,
updateDatabase: false,
unusedFallbackKey: true);
// cross sign the device from our currently signed in device
await updateUserDeviceKeys(additionalUsers: {userID!});
final keysToSign = <SignableKey>[
userDeviceKeys[userID]!.deviceKeys[device]!,
];
await this.encryption?.crossSigning.sign(keysToSign);
} finally {
await encryption.dispose();
}
}
}

View File

@ -151,6 +151,7 @@ class Client extends MatrixApi {
/// sending events on connection problems or to `Duration.zero` to disable it.
/// Set [customImageResizer] to your own implementation for a more advanced
/// and faster image resizing experience.
/// Set [enableDehydratedDevices] to enable experimental support for enabling MSC3814 dehydrated devices.
Client(
this.clientName, {
this.databaseBuilder,
@ -175,6 +176,7 @@ class Client extends MatrixApi {
this.sendTimelineEventTimeout = const Duration(minutes: 1),
this.customImageResizer,
this.shareKeysWithUnverifiedDevices = true,
this.enableDehydratedDevices = false,
}) : syncFilter = syncFilter ??
Filter(
room: RoomFilter(
@ -252,11 +254,13 @@ class Client extends MatrixApi {
List<Room> get rooms => _rooms;
List<Room> _rooms = [];
bool enableDehydratedDevices = false;
/// Whether this client supports end-to-end encryption using olm.
bool get encryptionEnabled => encryption?.enabled == true;
/// Whether this client is able to encrypt and decrypt files.
bool get fileEncryptionEnabled => encryptionEnabled && true;
bool get fileEncryptionEnabled => encryptionEnabled;
String get identityKey => encryption?.identityKey ?? '';
@ -2187,6 +2191,9 @@ class Client extends MatrixApi {
// Check if there are outdated device key lists. Add it to the set.
final outdatedLists = <String, List<String>>{};
for (final userId in (additionalUsers ?? <String>[])) {
outdatedLists[userId] = [];
}
for (final userId in trackedUserIds) {
final deviceKeysList =
_userDeviceKeys[userId] ??= DeviceKeysList(userId, this);

View File

@ -14,7 +14,7 @@ dependencies:
canonical_json: ^1.1.0
collection: ^1.15.0
crypto: ^3.0.0
ffi: ^1.0.0
ffi: ^2.0.0
fluffybox: ^0.4.3
hive: ^2.2.1
html: ^0.15.0
@ -23,9 +23,9 @@ dependencies:
image: ^3.1.1
js: ^0.6.3
markdown: ^4.0.0
matrix_api_lite: ^1.1.0
matrix_api_lite: ^1.1.7
mime: ^1.0.0
olm: ^2.0.0
olm: ^2.0.2
random_string: ^2.3.1
sdp_transform: ^0.3.2
slugify: ^2.0.0

View File

@ -59,7 +59,7 @@ void main() {
} else if (bootstrap.state == BootstrapState.askNewSsss) {
await bootstrap.newSsss('foxies');
} else if (bootstrap.state == BootstrapState.askWipeCrossSigning) {
bootstrap.wipeCrossSigning(true);
await bootstrap.wipeCrossSigning(true);
} else if (bootstrap.state == BootstrapState.askSetupCrossSigning) {
await bootstrap.askSetupCrossSigning(
setupMasterKey: true,
@ -120,7 +120,7 @@ void main() {
} else if (bootstrap.state == BootstrapState.askNewSsss) {
await bootstrap.newSsss('newfoxies');
} else if (bootstrap.state == BootstrapState.askWipeCrossSigning) {
bootstrap.wipeCrossSigning(false);
await bootstrap.wipeCrossSigning(false);
} else if (bootstrap.state == BootstrapState.askWipeOnlineKeyBackup) {
bootstrap.wipeOnlineKeyBackup(false);
}
@ -173,7 +173,7 @@ void main() {
} else if (bootstrap.state == BootstrapState.askNewSsss) {
await bootstrap.newSsss('supernewfoxies');
} else if (bootstrap.state == BootstrapState.askWipeCrossSigning) {
bootstrap.wipeCrossSigning(false);
await bootstrap.wipeCrossSigning(false);
} else if (bootstrap.state == BootstrapState.askWipeOnlineKeyBackup) {
bootstrap.wipeOnlineKeyBackup(false);
}

View File

@ -1945,6 +1945,33 @@ class FakeMatrixApi extends BaseClient {
},
},
},
'/client/unstable/org.matrix.msc3814.v1/dehydrated_device': (var _) => {
'device_id': 'DEHYDDEV',
'device_data': {'algorithm': 'some.famedly.proprietary.algorithm'},
},
'/client/unstable/org.matrix.msc3814.v1/dehydrated_device/DEHYDDEV/events?limit=100':
(var _) => {
'events': [
{
// this is the commented out m.room_key event - only encrypted
'sender': '@othertest:fakeServer.notExisting',
'content': {
'algorithm': AlgorithmTypes.olmV1Curve25519AesSha2,
'sender_key':
'JBG7ZaPn54OBC7TuIEiylW3BZ+7WcGQhFBPB9pogbAg',
'ciphertext': {
'7rvl3jORJkBiK4XX1e5TnGnqz068XfYJ0W++Ml63rgk': {
'type': 0,
'body':
'Awogyh7K4iLUQjcOxIfi7q7LhBBqv9w0mQ6JI9+U9tv7iF4SIHC6xb5YFWf9voRnmDBbd+0vxD/xDlVNRDlPIKliLGkYGiAkEbtlo+fng4ELtO4gSLKVbcFn7tZwZCEUE8H2miBsCCKABgMKIFrKDJwB7gM3lXPt9yVoh6gQksafKt7VFCNRN5KLKqsDEAAi0AX5EfTV7jJ1ZWAbxftjoSN6kCVIxzGclbyg1HjchmNCX7nxNCHWl+q5ZgqHYZVu2n2mCVmIaKD0kvoEZeY3tV1Itb6zf67BLaU0qgW/QzHCHg5a44tNLjucvL2mumHjIG8k0BY2uh+52HeiMCvSOvtDwHg7nzCASGdqPVCj9Kzw6z7F6nL4e3mYim8zvJd7f+mD9z3ARrypUOLGkTGYbB2PQOovf0Do8WzcaRzfaUCnuu/YVZWKK7DPgG8uhw/TjR6XtraAKZysF+4DJYMG9SQWx558r6s7Z5EUOF5CU2M35w1t1Xxllb3vrS83dtf9LPCrBhLsEBeYEUBE2+bTBfl0BDKqLiB0Cc0N0ixOcHIt6e40wAvW622/gMgHlpNSx8xG12u0s6h6EMWdCXXLWd9fy2q6glFUHvA67A35q7O+M8DVml7Y9xG55Y3DHkMDc9cwgwFkBDCAYQe6pQF1nlKytcVCGREpBs/gq69gHAStMQ8WEg38Lf8u8eBr2DFexrN4U+QAk+S//P3fJgf0bQx/Eosx4fvWSz9En41iC+ADCsWQpMbwHn4JWvtAbn3oW0XmL/OgThTkJMLiCymduYAa1Hnt7a3tP0KTL2/x11F02ggQHL28cCjq5W4zUGjWjl5wo2PsKB6t8aAvMg2ujGD2rCjb4yrv5VIzAKMOZLyj7K0vSK9gwDLQ/4vq+QnKUBG5zrcOze0hX+kz2909/tmAdeCH61Ypw7gbPUJAKnmKYUiB/UgwkJvzMJSsk/SEs5SXosHDI+HsJHJp4Mp4iKD0xRMst+8f9aTjaWwh8ZvELE1ZOhhCbF3RXhxi3x2Nu8ORIz+vhEQ1NOlMc7UIo98Fk/96T36vL/fviowT4C/0AlaapZDJBmKwhmwqisMjY2n1vY29oM2p5BzY1iwP7q9BYdRFst6xwo57TNSuRwQw7IhFsf0k+ABuPEZy5xB5nPHyIRTf/pr3Hw',
},
},
},
'type': 'm.room.encrypted',
},
],
'next_batch': 'd1',
},
},
'POST': {
'/client/v3/delete_devices': (var req) => {},
@ -2479,6 +2506,9 @@ class FakeMatrixApi extends BaseClient {
'etag': 'asdf',
'count': 1,
},
'/client/unstable/org.matrix.msc3814.v1/dehydrated_device': (var _) => {
'device_id': 'DEHYDDEV',
},
},
'DELETE': {
'/unknown/token': (var req) => {'errcode': 'M_UNKNOWN_TOKEN'},

View File

@ -0,0 +1,54 @@
/* MIT License
*
* Copyright (C) 2019, 2020, 2021, 2022 Famedly GmbH
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import 'package:test/test.dart';
import 'package:matrix/msc_extensions/msc_3814_dehydrated_devices/api.dart';
import '../fake_client.dart';
import '../fake_matrix_api.dart';
void main() {
/// All Tests related to device keys
group('Dehydrated Devices', () {
test('API calls', () async {
final client = await getClient();
final ret = await client.uploadDehydratedDevice(
initialDeviceDisplayName: 'DehydratedDevice',
deviceData: {'algorithm': 'some.famedly.proprietary.algorith'});
expect(
FakeMatrixApi.calledEndpoints.containsKey(
'/client/unstable/org.matrix.msc3814.v1/dehydrated_device'),
true);
expect(ret.isNotEmpty, true);
final device = await client.getDehydratedDevice();
expect(device.deviceId, 'DEHYDDEV');
expect(device.deviceData?['algorithm'],
'some.famedly.proprietary.algorithm');
final events = await client.getDehydratedDeviceEvents(device.deviceId);
expect(events.events?.length, 1);
expect(events.nextBatch, 'd1');
});
});
}