diff --git a/lib/encryption/encryption.dart b/lib/encryption/encryption.dart index f49a175c9..48a7ce4f9 100644 --- a/lib/encryption/encryption.dart +++ b/lib/encryption/encryption.dart @@ -27,6 +27,7 @@ import 'cross_signing.dart'; import 'key_manager.dart'; import 'key_verification_manager.dart'; import 'olm_manager.dart'; +import 'push_helper.dart'; import 'ssss.dart'; import 'utils/bootstrap.dart'; @@ -48,6 +49,7 @@ class Encryption { late KeyVerificationManager keyVerificationManager; late CrossSigning crossSigning; late SSSS ssss; + late PushHelper pushHelper; Encryption({ required this.client, @@ -58,11 +60,13 @@ class Encryption { olmManager = OlmManager(this); keyVerificationManager = KeyVerificationManager(this); crossSigning = CrossSigning(this); + pushHelper = PushHelper(this); } // initial login passes null to init a new olm account - Future init(String? olmAccount) async { + Future init({String? olmAccount, String? pushPrivateKey}) async { await olmManager.init(olmAccount); + await pushHelper.init(pushPrivateKey); _backgroundTasksRunning = true; _backgroundTasks(); // start the background tasks } diff --git a/lib/encryption/push_helper.dart b/lib/encryption/push_helper.dart new file mode 100644 index 000000000..5f715b3bd --- /dev/null +++ b/lib/encryption/push_helper.dart @@ -0,0 +1,205 @@ +/* + * Famedly Matrix SDK + * Copyright (C) 2021 Famedly GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:olm/olm.dart' as olm; +import 'package:cryptography/cryptography.dart'; + +import '../matrix.dart'; +import 'encryption.dart'; + +class PushHelper { + final Encryption encryption; + Client get client => encryption.client; + Uint8List? privateKey; + String? publicKey; + + PushHelper(this.encryption); + + /// base64 decode both padded and unpadded base64 + Uint8List _b64decode(String s) { + // dart wants padded base64: https://github.com/dart-lang/sdk/issues/39510 + final needEquals = (4 - (s.length % 4)) % 4; + return base64.decode(s + ('=' * needEquals)); + } + + /// Decrypt a given [ciphertext] and [ephemeral] key, validating the [mac] + Future _pkDecrypt( + {required String ciphertext, + required String mac, + required String ephemeral}) async { + final _privateKey = privateKey; + if (_privateKey == null) { + throw Exception('No private key to decrypt with'); + } + + // first we do ECDH (x25519) with the ephemeral key, the resulting secret lands in `secretKey` + final x25519 = Cryptography.instance.x25519(); + final secretKey = await x25519.sharedSecretKey( + keyPair: await x25519.newKeyPairFromSeed(_privateKey), + remotePublicKey: + SimplePublicKey(_b64decode(ephemeral), type: KeyPairType.x25519), + ); + + // next we do HKDF to get the aesKey, macKey and aesIv + final zerosalt = List.filled(32, 0); + final hmac = Hmac.sha256(); + final prk = (await hmac.calculateMac(await secretKey.extractBytes(), + secretKey: SecretKey(zerosalt))) + .bytes; + final aesKey = + (await hmac.calculateMac([1], secretKey: SecretKey(prk))).bytes; + final macKey = + (await hmac.calculateMac([...aesKey, 2], secretKey: SecretKey(prk))) + .bytes; + final aesIv = + (await hmac.calculateMac([...macKey, 3], secretKey: SecretKey(prk))) + .bytes + .sublist(0, 16); + + // now we calculate and compare the macs + final resMac = (await hmac.calculateMac(_b64decode(ciphertext), + secretKey: SecretKey(macKey))) + .bytes + .sublist(0, 8); + if (base64.encode(resMac).replaceAll('=', '') != mac.replaceAll('=', '')) { + throw Exception('Bad mac'); + } + + // finally decrypt the actual ciphertext + final aes = AesCbc.with256bits(macAlgorithm: MacAlgorithm.empty); + final decrypted = await aes.decrypt( + SecretBox(_b64decode(ciphertext), nonce: aesIv, mac: Mac.empty), + secretKey: SecretKey(aesKey)); + return utf8.decode(decrypted); + } + + /// Process a push payload, decrypting it based on its algorithm + Future> processPushPayload( + Map data) async { + var algorithm = data.tryGet('algorithm'); + if (algorithm == null) { + List>? devices; + if (data['devices'] is String) { + devices = json.decode(data['devices']).cast>(); + } else { + devices = data.tryGetList>('devices'); + } + if (devices != null && devices.isNotEmpty) { + algorithm = devices.first + .tryGetMap('data') + ?.tryGet('algorithm'); + } + } + Logs().v('[Push] Using algorithm: $algorithm'); + switch (algorithm) { + case 'com.famedly.curve25519-aes-sha2': + final ciphertext = data.tryGet('ciphertext'); + final mac = data.tryGet('mac'); + final ephemeral = data.tryGet('ephemeral'); + if (ciphertext == null || mac == null || ephemeral == null) { + throw Exception('Invalid encrypted push payload'); + } + return json.decode(await _pkDecrypt( + ciphertext: ciphertext, + mac: mac, + ephemeral: ephemeral, + )); + default: + return data; + } + } + + /// Initialize the push helper with a [pushPrivateKey], generating a new keypair + /// if none passed or empty + Future init([String? pushPrivateKey]) async { + if (pushPrivateKey != null && pushPrivateKey.isNotEmpty) { + try { + final _privateKey = base64.decode(pushPrivateKey); + final keyObj = olm.PkDecryption(); + try { + publicKey = keyObj.init_with_private_key(_privateKey); + privateKey = _privateKey; + } finally { + keyObj.free(); + } + } catch (e, s) { + client.onEncryptionError.add( + SdkError( + exception: e is Exception ? e : Exception(e), + stackTrace: s, + ), + ); + privateKey = null; + publicKey = null; + } + } else { + privateKey = null; + publicKey = null; + } + await _maybeGenerateNewKeypair(); + } + + /// Transmutes a pusher to add the public key and algorithm. Additionally generates a + /// new keypair, if needed + Future getPusher(Pusher pusher) async { + await _maybeGenerateNewKeypair(); + if (privateKey == null) { + throw Exception('No private key found'); + } + final newPusher = Pusher.fromJson(pusher.toJson()); + newPusher.data = PusherData.fromJson({ + ...newPusher.data.toJson(), + 'public_key': publicKey, + 'algorithm': 'com.famedly.curve25519-aes-sha2', + }); + return newPusher; + } + + /// Force generation of a new keypair + Future generateNewKeypair() async { + try { + final keyObj = olm.PkDecryption(); + try { + publicKey = keyObj.generate_key(); + privateKey = keyObj.get_private_key(); + } finally { + keyObj.free(); + } + await client.database?.storePushPrivateKey(base64.encode(privateKey!)); + } catch (e, s) { + client.onEncryptionError.add( + SdkError( + exception: e is Exception ? e : Exception(e), + stackTrace: s, + ), + ); + rethrow; + } + } + + /// Generate a new keypair only if there is none + Future _maybeGenerateNewKeypair() async { + if (privateKey != null) { + return; + } + await generateNewKeypair(); + } +} diff --git a/lib/matrix.dart b/lib/matrix.dart index 24a0c9ef6..f5615cbc2 100644 --- a/lib/matrix.dart +++ b/lib/matrix.dart @@ -41,6 +41,7 @@ export 'src/utils/image_pack_extension.dart'; export 'src/utils/matrix_file.dart'; export 'src/utils/matrix_id_string_extension.dart'; export 'src/utils/matrix_localizations.dart'; +export 'src/utils/push_helper_extension.dart'; export 'src/utils/receipt.dart'; export 'src/utils/sync_update_extension.dart'; export 'src/utils/to_device_event.dart'; diff --git a/lib/src/client.dart b/lib/src/client.dart index 1f4a2c629..57f3df40a 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -1027,6 +1027,7 @@ class Client extends MatrixApi { String? olmAccount; String? accessToken; String? _userID; + String? pushPrivateKey; final account = await this.database?.getClient(clientName); if (account != null) { _id = account['client_id']; @@ -1038,6 +1039,7 @@ class Client extends MatrixApi { syncFilterId = account['sync_filter_id']; prevBatch = account['prev_batch']; olmAccount = account['olm_account']; + pushPrivateKey = account['push_private_key']; } if (newToken != null) { accessToken = this.accessToken = newToken; @@ -1079,7 +1081,10 @@ class Client extends MatrixApi { encryption?.dispose(); encryption = null; } - await encryption?.init(olmAccount); + await encryption?.init( + olmAccount: olmAccount, + pushPrivateKey: pushPrivateKey, + ); final database = this.database; if (database != null) { diff --git a/lib/src/database/database_api.dart b/lib/src/database/database_api.dart index 2e3b754e4..f38b5d412 100644 --- a/lib/src/database/database_api.dart +++ b/lib/src/database/database_api.dart @@ -91,6 +91,10 @@ abstract class DatabaseApi { String syncFilterId, ); + Future storePushPrivateKey( + String? pushPrivateKey, + ); + Future storeAccountData(String type, String content); Future> getUserDeviceKeys(Client client); diff --git a/lib/src/database/fluffybox_database.dart b/lib/src/database/fluffybox_database.dart index 720cf1a42..c37955b42 100644 --- a/lib/src/database/fluffybox_database.dart +++ b/lib/src/database/fluffybox_database.dart @@ -1216,6 +1216,17 @@ class FluffyBoxDatabase extends DatabaseApi { await _clientBox.put('sync_filter_id', syncFilterId); } + @override + Future storePushPrivateKey( + String? pushPrivateKey, + ) async { + if (pushPrivateKey == null) { + await _clientBox.delete('push_private_key'); + } else { + await _clientBox.put('push_private_key', pushPrivateKey); + } + } + @override Future storeUserCrossSigningKey(String userId, String publicKey, String content, bool verified, bool blocked) async { diff --git a/lib/src/database/hive_database.dart b/lib/src/database/hive_database.dart index 8cdb0acff..4ceb1f6fb 100644 --- a/lib/src/database/hive_database.dart +++ b/lib/src/database/hive_database.dart @@ -684,14 +684,15 @@ class FamedlySdkHiveDatabase extends DatabaseApi { @override Future insertClient( - String name, - String homeserverUrl, - String token, - String userId, - String? deviceId, - String? deviceName, - String? prevBatch, - String? olmAccount) async { + String name, + String homeserverUrl, + String token, + String userId, + String? deviceId, + String? deviceName, + String? prevBatch, + String? olmAccount, + ) async { await _clientBox.put('homeserver_url', homeserverUrl); await _clientBox.put('token', token); await _clientBox.put('user_id', userId); @@ -1185,6 +1186,13 @@ class FamedlySdkHiveDatabase extends DatabaseApi { await _clientBox.put('sync_filter_id', syncFilterId); } + @override + Future storePushPrivateKey( + String? pushPrivateKey, + ) async { + await _clientBox.put('push_private_key', pushPrivateKey); + } + @override Future storeUserCrossSigningKey(String userId, String publicKey, String content, bool verified, bool blocked) async { diff --git a/lib/src/utils/push_helper_extension.dart b/lib/src/utils/push_helper_extension.dart new file mode 100644 index 000000000..fd9e6d268 --- /dev/null +++ b/lib/src/utils/push_helper_extension.dart @@ -0,0 +1,114 @@ +/* + * Famedly Matrix SDK + * Copyright (C) 2021 Famedly GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import '../../matrix.dart'; + +extension PushHelperClientExtension on Client { + /// Set up a pusher: The optionally provided [pusher] is set up, removing old pushers + /// and enabeling encrypted push, if available and [allowEncryption] is set to + /// true (default). Additionally, optionally passed [oldTokens] are removed. + /// If an [encryptedPusher] is set, then this one is used, if encryption is available. + /// This allows to e.g. send full-event push notifications only on encrypted push. + Future setupPusher({ + Set? oldTokens, + Pusher? pusher, + bool allowEncryption = true, + Pusher? encryptedPusher, + }) async { + // first we test if the server supports encrypted push + var haveEncryptedPush = false; + if (pusher != null && encryptionEnabled && allowEncryption) { + final versions = await getVersions(); + if (versions.unstableFeatures != null) { + haveEncryptedPush = + versions.unstableFeatures!['com.famedly.msc3013'] == true; + } + } + // if the server *does* support encrypted push, we turn the pusher into an encrypted pusher + final newPusher = haveEncryptedPush && pusher != null + ? await encryption!.pushHelper.getPusher(encryptedPusher ?? pusher) + : pusher; + + final pushers = await getPushers().catchError((e) { + return []; + }); + oldTokens ??= {}; + var setNewPusher = false; + if (newPusher != null) { + // if we want to set a new pusher, we should look for if it already exists or needs updating + final currentPushers = + pushers?.where((p) => p.pushkey == newPusher.pushkey) ?? []; + if (currentPushers.length == 1 && + currentPushers.first.kind == newPusher.kind && + currentPushers.first.appId == newPusher.appId && + currentPushers.first.appDisplayName == newPusher.appDisplayName && + currentPushers.first.lang == newPusher.lang && + currentPushers.first.data.url.toString() == + newPusher.data.url.toString() && + currentPushers.first.data.format == newPusher.data.format && + currentPushers.first.data.additionalProperties['algorithm'] == + newPusher.data.additionalProperties['algorithm'] && + currentPushers.first.data.additionalProperties['public_key'] == + newPusher.data.additionalProperties['public_key']) { + Logs().i('[Push] Pusher already set'); + } else { + Logs().i('[Push] Need to set new pusher'); + // there is an outdated version of this pusher, queue it for removal + oldTokens.add(newPusher.pushkey); + if (isLogged()) { + setNewPusher = true; + } + } + } + // remove all old, outdated pushers + for (final oldPusher in pushers ?? []) { + if ((newPusher != null && + oldPusher.pushkey != newPusher.pushkey && + oldPusher.appId == newPusher.appId) || + oldTokens.contains(oldPusher.pushkey)) { + try { + await deletePusher(oldPusher); + Logs().i('[Push] Removed legacy pusher for this device'); + } catch (err) { + Logs().w('[Push] Failed to remove old pusher', err); + } + } + } + // and finally set the new pusher + if (setNewPusher && newPusher != null) { + try { + await postPusher(newPusher, append: false); + } catch (e, s) { + Logs().e('[Push] Unable to set pushers', e, s); + rethrow; + } + } + } + + /// Process a push payload, handeling encrypted push etc. + Future> processPushPayload( + Map payload) async { + final data = payload.tryGetMap('notification') ?? + payload.tryGetMap('data') ?? + payload; + if (encryptionEnabled) { + return await encryption!.pushHelper.processPushPayload(data); + } + return data; + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 5c5b91f63..0bf19bd13 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ dependencies: crypto: ^3.0.0 base58check: ^2.0.0 olm: ^2.0.0 - matrix_api_lite: ^0.5.1 + matrix_api_lite: ^0.5.3 hive: ^2.0.4 ffi: ^1.0.0 js: ^0.6.3 @@ -26,6 +26,7 @@ dependencies: webrtc_interface: ^1.0.1 sdp_transform: ^0.3.2 fluffybox: ^0.4.1 + cryptography: ^2.0.2 dev_dependencies: dart_code_metrics: ^4.4.0 diff --git a/test/encryption/push_helper_test.dart b/test/encryption/push_helper_test.dart new file mode 100644 index 000000000..8fd847bab --- /dev/null +++ b/test/encryption/push_helper_test.dart @@ -0,0 +1,151 @@ +/* + * Famedly Matrix SDK + * Copyright (C) 2021 Famedly GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'dart:convert'; +import 'package:test/test.dart'; +import 'package:matrix/matrix.dart'; +import 'package:olm/olm.dart' as olm; +import '../fake_client.dart'; + +final privateKey = 'ocE2RWd/yExYEk0JCAx3100//WQkmM3syidCVFsndS0='; + +final wantPublicKey = 'odb+sBwaK0bZtaAqzcuFR3UVg5Wa1cW7ZMwJY1SnDng'; + +final rawJson = + '{"ciphertext":"S7EYruu1f3Z1PkYnx/O3bw8OxCbWavLih10CpSm/msfPJ6ho4OcHa+6eYAPQCZp4MVuvadGfHVdTpdinzMUCJJvIkRbFU4rYN3HsfIhYni1pdknPQ+9AGXkxUIlmfmziZwObGOFfX1HwyTOykrZEIEQj0oKGK4psSi8BwRv+D2bvPkYBCeZiAKr5dSkOoZo4Lkoe7Q2a41nr2d23+ZTn7Q","devices":[{"app_id":"encrypted-push","data":{"algorithm":"com.famedly.curve25519-aes-sha2","format":"event_id_only"},"pushkey":"https://gotify.luckyskies.pet/UP?token=AqXZS.CM7VI0F2V","pushkey_ts":1635499243}],"ephemeral":"kxEHE2fYpCO9Go35MV7DmjIW22A1zCw32PeHEUuXkQs","mac":"8/sK41zVaPU"}'; + +void main() { + group('Push Helper', () { + Logs().level = Level.error; + var olmEnabled = true; + + late Client client; + + test('setupClient', () async { + if (!olmEnabled) return; + try { + await olm.init(); + olm.get_library_version(); + } catch (e) { + olmEnabled = false; + Logs().w('[LibOlm] Failed to load LibOlm', e); + } + Logs().i('[LibOlm] Enabled: $olmEnabled'); + if (!olmEnabled) return; + + client = await getClient(); + }); + test('decrypt an encrypted push payload', () async { + if (!olmEnabled) return; + await client.encryption!.pushHelper.init(privateKey); + expect(client.encryption!.pushHelper.publicKey, wantPublicKey); + final ret = await client.encryption!.pushHelper + .processPushPayload(json.decode(rawJson)); + expect(ret, { + 'event_id': '\$0VwDWKcBsKnANLsgZyBiuUxpRfkj-Bj7fDTW2jpuwXY', + 'room_id': '!GQUqohCDwvpnSczitP:nheko.im', + 'counts': {'unread': 1}, + 'prio': 'high' + }); + }); + test('decrypt a top-level algorithm payload', () async { + if (!olmEnabled) return; + await client.encryption!.pushHelper.init(privateKey); + expect(client.encryption!.pushHelper.publicKey, wantPublicKey); + final j = json.decode(rawJson); + j['algorithm'] = j['devices'].first['data']['algorithm']; + j.remove('devices'); + final ret = await client.encryption!.pushHelper.processPushPayload(j); + expect(ret, { + 'event_id': '\$0VwDWKcBsKnANLsgZyBiuUxpRfkj-Bj7fDTW2jpuwXY', + 'room_id': '!GQUqohCDwvpnSczitP:nheko.im', + 'counts': {'unread': 1}, + 'prio': 'high' + }); + }); + test('decrypt the plain payload format', () async { + if (!olmEnabled) return; + await client.encryption!.pushHelper.init(privateKey); + expect(client.encryption!.pushHelper.publicKey, wantPublicKey); + final j = json.decode(rawJson); + final ret = await client.encryption!.pushHelper.processPushPayload(j); + expect(ret, { + 'event_id': '\$0VwDWKcBsKnANLsgZyBiuUxpRfkj-Bj7fDTW2jpuwXY', + 'room_id': '!GQUqohCDwvpnSczitP:nheko.im', + 'counts': {'unread': 1}, + 'prio': 'high' + }); + }); + test('decrypt an fcm push payload', () async { + if (!olmEnabled) return; + await client.encryption!.pushHelper.init(privateKey); + final j = json.decode(rawJson); + j['devices'] = json.encode(j['devices']); + final ret = await client.encryption!.pushHelper.processPushPayload(j); + expect(ret, { + 'event_id': '\$0VwDWKcBsKnANLsgZyBiuUxpRfkj-Bj7fDTW2jpuwXY', + 'room_id': '!GQUqohCDwvpnSczitP:nheko.im', + 'counts': {'unread': 1}, + 'prio': 'high' + }); + }); + test('handle the plain algorithm', () async { + if (!olmEnabled) return; + await client.encryption!.pushHelper.init(privateKey); + expect(client.encryption!.pushHelper.publicKey, wantPublicKey); + final j = json.decode(rawJson); + j['devices'].first['data']['algorithm'] = 'm.plain'; + final ret = await client.encryption!.pushHelper.processPushPayload(j); + expect(ret['mac'], '8/sK41zVaPU'); + }); + test('handle the absense of an algorithm', () async { + if (!olmEnabled) return; + await client.encryption!.pushHelper.init(privateKey); + expect(client.encryption!.pushHelper.publicKey, wantPublicKey); + final j = json.decode(rawJson); + j['devices'].first['data'].remove('algorithm'); + final ret = await client.encryption!.pushHelper.processPushPayload(j); + expect(ret['mac'], '8/sK41zVaPU'); + }); + test('getPusher', () async { + if (!olmEnabled) return; + await client.encryption!.pushHelper.init(); + final oldPusher = Pusher.fromJson({ + 'app_display_name': 'Appy McAppface', + 'app_id': 'face.mcapp.appy.prod', + 'data': {'url': 'https://example.com/_matrix/push/v1/notify'}, + 'device_display_name': 'Foxies', + 'kind': 'http', + 'lang': 'en-US', + 'profile_tag': 'xyz', + 'pushkey': 'Xp/MzCt8/9DcSNE9cuiaoT5Ac55job3TdLSSmtmYl4A=' + }); + final newPusher = + await client.encryption!.pushHelper.getPusher(oldPusher); + expect(newPusher.data.additionalProperties['public_key'] is String, true); + expect(newPusher.data.additionalProperties['public_key'], + client.encryption!.pushHelper.publicKey); + expect(newPusher.data.additionalProperties['algorithm'], + 'com.famedly.curve25519-aes-sha2'); + }); + test('dispose client', () async { + if (!olmEnabled) return; + await client.dispose(closeDatabase: true); + }); + }); +} diff --git a/test/fake_matrix_api.dart b/test/fake_matrix_api.dart index 93fad68bd..6af45ef39 100644 --- a/test/fake_matrix_api.dart +++ b/test/fake_matrix_api.dart @@ -1350,7 +1350,10 @@ class FakeMatrixApi extends MockClient { 'r0.4.0', 'r0.5.0' ], - 'unstable_features': {'m.lazy_load_members': true}, + 'unstable_features': { + 'm.lazy_load_members': true, + 'com.famedly.msc3013': true + }, }, '/client/r0/login': (var req) => { 'flows': [ diff --git a/test/push_helper_test.dart b/test/push_helper_test.dart new file mode 100644 index 000000000..a17676139 --- /dev/null +++ b/test/push_helper_test.dart @@ -0,0 +1,176 @@ +/* + * Famedly Matrix SDK + * Copyright (C) 2021 Famedly GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'dart:convert'; +import 'package:test/test.dart'; +import 'package:matrix/matrix.dart'; +import 'fake_client.dart'; +import 'fake_matrix_api.dart'; +import 'package:olm/olm.dart' as olm; + +final privateKey = 'ocE2RWd/yExYEk0JCAx3100//WQkmM3syidCVFsndS0='; +final rawJson = + '{"notification":{"ciphertext":"S7EYruu1f3Z1PkYnx/O3bw8OxCbWavLih10CpSm/msfPJ6ho4OcHa+6eYAPQCZp4MVuvadGfHVdTpdinzMUCJJvIkRbFU4rYN3HsfIhYni1pdknPQ+9AGXkxUIlmfmziZwObGOFfX1HwyTOykrZEIEQj0oKGK4psSi8BwRv+D2bvPkYBCeZiAKr5dSkOoZo4Lkoe7Q2a41nr2d23+ZTn7Q","devices":[{"app_id":"encrypted-push","data":{"algorithm":"com.famedly.curve25519-aes-sha2","format":"event_id_only"},"pushkey":"https://gotify.luckyskies.pet/UP?token=AqXZS.CM7VI0F2V","pushkey_ts":1635499243}],"ephemeral":"kxEHE2fYpCO9Go35MV7DmjIW22A1zCw32PeHEUuXkQs","mac":"8/sK41zVaPU"}}'; + +void main() { + group('Push Helper', () { + Logs().level = Level.error; + var olmEnabled = true; + + late Client client; + test('setupClient', () async { + try { + await olm.init(); + olm.get_library_version(); + } catch (e) { + olmEnabled = false; + Logs().w('[LibOlm] Failed to load LibOlm', e); + } + Logs().i('[LibOlm] Enabled: $olmEnabled'); + client = await getClient(); + }); + test('setupPusher sets a new pusher', () async { + final pusher = Pusher.fromJson({ + 'pushkey': 'newpusher', + 'kind': 'http', + 'app_id': 'fox.floof', + 'app_display_name': 'Floofer', + 'device_display_name': 'Fox Phone', + 'profile_tag': 'xyz', + 'lang': 'en-US', + 'data': { + 'url': 'https://fox.floof/_matrix/push/v1/notify', + 'format': 'event_id_only', + }, + }); + FakeMatrixApi.calledEndpoints.clear(); + await client.setupPusher(pusher: pusher); + expect( + FakeMatrixApi.calledEndpoints['/client/r0/pushers/set']!.length, 1); + final sentJson = json.decode( + FakeMatrixApi.calledEndpoints['/client/r0/pushers/set']!.first); + if (olmEnabled) { + expect(sentJson['data']['public_key'] is String, true); + expect( + sentJson['data']['algorithm'], 'com.famedly.curve25519-aes-sha2'); + sentJson['data'].remove('public_key'); + sentJson['data'].remove('algorithm'); + } + expect(sentJson, { + ...pusher.toJson(), + 'append': false, + }); + }); + test('setupPusher does nothing if the pusher already exists', () async { + final encryption = client.encryption; + client.encryption = null; + + final pusher = Pusher.fromJson({ + 'pushkey': 'Xp/MzCt8/9DcSNE9cuiaoT5Ac55job3TdLSSmtmYl4A=', + 'kind': 'http', + 'app_id': 'face.mcapp.appy.prod', + 'app_display_name': 'Appy McAppface', + 'device_display_name': 'Alices Phone', + 'profile_tag': 'xyz', + 'lang': 'en-US', + 'data': { + 'url': 'https://example.com/_matrix/push/v1/notify', + 'format': 'event_id_only', + }, + }); + FakeMatrixApi.calledEndpoints.clear(); + await client.setupPusher(pusher: pusher); + expect(FakeMatrixApi.calledEndpoints['/client/r0/pushers/set'], null); + + client.encryption = encryption; + }); + test('setupPusher deletes old push keys provided', () async { + FakeMatrixApi.calledEndpoints.clear(); + await client.setupPusher( + oldTokens: {'Xp/MzCt8/9DcSNE9cuiaoT5Ac55job3TdLSSmtmYl4A='}); + expect( + FakeMatrixApi.calledEndpoints['/client/r0/pushers/set']!.length, 1); + final sentJson = json.decode( + FakeMatrixApi.calledEndpoints['/client/r0/pushers/set']!.first); + expect(sentJson['kind'], null); + }); + test('setupPusher auto-updates a pusher, if it already exists', () async { + final encryption = client.encryption; + client.encryption = null; + + final pusher = Pusher.fromJson({ + 'pushkey': 'Xp/MzCt8/9DcSNE9cuiaoT5Ac55job3TdLSSmtmYl4A=', + 'kind': 'http', + 'app_id': 'face.mcapp.appy.prod', + 'app_display_name': 'Appy McAppface Flooftacular', + 'device_display_name': 'Alices Phone', + 'profile_tag': 'xyz', + 'lang': 'en-US', + 'data': { + 'url': 'https://example.com/_matrix/push/v1/notify', + 'format': 'event_id_only', + }, + }); + FakeMatrixApi.calledEndpoints.clear(); + await client.setupPusher(pusher: pusher); + expect( + FakeMatrixApi.calledEndpoints['/client/r0/pushers/set']!.length, 2); + var sentJson = json.decode( + FakeMatrixApi.calledEndpoints['/client/r0/pushers/set']!.first); + expect(sentJson['kind'], null); + sentJson = json + .decode(FakeMatrixApi.calledEndpoints['/client/r0/pushers/set']![1]); + expect(sentJson, { + ...pusher.toJson(), + 'append': false, + }); + + client.encryption = encryption; + }); + test('processPushPayload no libolm', () async { + final encryption = client.encryption; + client.encryption = null; + var ret = await client.processPushPayload({ + 'notification': {'fox': 'floof'} + }); + expect(ret, {'fox': 'floof'}); + ret = await client.processPushPayload({ + 'data': {'fox': 'floof'} + }); + expect(ret, {'fox': 'floof'}); + ret = await client.processPushPayload({'fox': 'floof'}); + expect(ret, {'fox': 'floof'}); + client.encryption = encryption; + }); + test('processPushPayload with libolm', () async { + if (!olmEnabled) return; + await client.encryption!.pushHelper.init(privateKey); + final ret = await client.encryption!.pushHelper + .processPushPayload(json.decode(rawJson)['notification']); + expect(ret, { + 'event_id': '\$0VwDWKcBsKnANLsgZyBiuUxpRfkj-Bj7fDTW2jpuwXY', + 'room_id': '!GQUqohCDwvpnSczitP:nheko.im', + 'counts': {'unread': 1}, + 'prio': 'high' + }); + }); + test('dispose client', () async { + await client.dispose(closeDatabase: true); + }); + }); +} diff --git a/test/room_test.dart b/test/room_test.dart index f381bf054..1b001f61c 100644 --- a/test/room_test.dart +++ b/test/room_test.dart @@ -1,6 +1,6 @@ /* * Famedly Matrix SDK - * Copyright (C) 2019, 2020 Famedly GmbH + * Copyright (C) 2021 Famedly GmbH * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as