From 6a0a252912ce1d5a65a839c91187cbb89160da2d Mon Sep 17 00:00:00 2001 From: td Date: Wed, 21 Jun 2023 19:15:26 +0530 Subject: [PATCH] feat: qr key verification --- lib/encryption/utils/base64_unpadded.dart | 4 + lib/encryption/utils/key_verification.dart | 589 ++++++++++++++++-- pubspec.yaml | 3 +- test/encryption/key_verification_test.dart | 341 +++++++++- .../encryption/qr_verification_self_test.dart | 536 ++++++++++++++++ test/fake_client.dart | 20 + test/fake_matrix_api.dart | 3 +- 7 files changed, 1424 insertions(+), 72 deletions(-) create mode 100644 test/encryption/qr_verification_self_test.dart diff --git a/lib/encryption/utils/base64_unpadded.dart b/lib/encryption/utils/base64_unpadded.dart index c66b64e8..160a8d11 100644 --- a/lib/encryption/utils/base64_unpadded.dart +++ b/lib/encryption/utils/base64_unpadded.dart @@ -11,3 +11,7 @@ Uint8List base64decodeUnpadded(String s) { final needEquals = (4 - (s.length % 4)) % 4; return base64.decode(s + ('=' * needEquals)); } + +String encodeBase64Unpadded(List s) { + return base64Encode(s).replaceAll(RegExp(r'=+$', multiLine: true), ''); +} diff --git a/lib/encryption/utils/key_verification.dart b/lib/encryption/utils/key_verification.dart index 489638e3..5e6fa75f 100644 --- a/lib/encryption/utils/key_verification.dart +++ b/lib/encryption/utils/key_verification.dart @@ -17,13 +17,17 @@ */ import 'dart:async'; +import 'dart:convert'; import 'dart:typed_data'; import 'package:canonical_json/canonical_json.dart'; import 'package:olm/olm.dart' as olm; +import 'package:typed_data/typed_data.dart'; import 'package:matrix/encryption/encryption.dart'; +import 'package:matrix/encryption/utils/base64_unpadded.dart'; import 'package:matrix/matrix.dart'; +import 'package:matrix/src/utils/crypto/crypto.dart' as uc; /* +-------------+ +-----------+ @@ -61,21 +65,115 @@ import 'package:matrix/matrix.dart'; | | */ +/// QR key verification +/// You create possible methods from `client.verificationMethods` on device A +/// and send a request using `request.start()` which calls `sendRequest()` your client +/// now is in `waitingAccept` state, where ideally your client would now show some +/// waiting indicator. +/// +/// On device B you now get a `m.key.verification.request`, you check the +/// `methods` from the request payload and see if anything is possible. +/// If not you cancel the request. (should this be cancelled? couldn't another device handle this?) +/// you the set the state to `askAccept`. +/// +/// Your client would now show a button to accept/decline the request. +/// The user can then use `acceptVerification()`to accept the verification which +/// then sends a `m.key.verification.ready`. This also calls `generateQrCode()` +/// in it which populates the `request.qrData` depending on the qr mode. +/// B now sets the state `askChoice` +/// +/// On device A you now get the ready event, which setups the `possibleMethods` +/// and `qrData` on A's side. Similarly A now sets their state to `askChoice` +/// +/// At his point both sides are on the `askChoice` state. +/// +/// BACKWARDS COMPATIBILITY HACK: +/// To work well with sdks prior to QR verification (0.20.5 and older), start will +/// be sent with ready itself if only sas is supported. This avoids weird glare +/// issues faced with start from both sides if clients are not on the same sdk +/// version (0.20.5 vs next) +/// https://matrix.to/#/!KBwfdofYJUmnsVoqwn:famedly.de/$wlHXlLQJdfrqKAF5KkuQrXydwOhY_uyqfH4ReasZqnA?via=neko.dev&via=famedly.de&via=lihotzki.de + +/// Here your clients would ideally show a list of the `possibleMethods` and the +/// user can choose one. For QR specifically, you can show the QR code on the +/// device which supports showing the qr code and the device which supports +/// scanning can scan this code. +/// +/// Assuming device A scans device B's code, device A would now send a `m.key.verification.start`, +/// you do this using the `continueVerificatio()` method. You can pass +/// `m.reciprocate.v1` or `m.sas.v1` here, and also attach the qrData here. +/// This then calls `verifyQrData()` internally, which sets the `randomSharedSecretForQRCode` +/// to the one from the QR code. Device A is now set to `showQRSuccess` state and shows +/// a green sheild. (Maybe add a text below saying tell device B you scanned the +/// code successfully.) +/// +/// (some keys magic happens here, check `verifyQrData()`, `verifyKeysQR()` to know more) +/// +/// On device B you get the `m.key.verification.start` event. The secret sent in +/// the start request is then verified, device B is then set to the `confirmQRScan` +/// state. Your device should show a dialog to confirm from B that A's device shows +/// the green shield (is in the done state). Once B confirms this physically, you +/// call the `acceptQRScanConfirmation()` function, which then does some keys +/// magic and sets B's state to `done`. +/// +/// A gets the `m.key.verification.done` messsage and sends a done back, both +/// users can now dismiss the verification dialog safely. + enum KeyVerificationState { + askChoice, askAccept, askSSSS, waitingAccept, askSas, + showQRSuccess, // scanner after QR scan was successfull + confirmQRScan, // shower after getting start waitingSas, done, error } -enum KeyVerificationMethod { emoji, numbers } +enum KeyVerificationMethod { emoji, numbers, qrShow, qrScan, reciprocate } + +bool isQrSupported(List knownVerificationMethods, List possibleMethods) { + return knownVerificationMethods.contains(EventTypes.QRShow) && + possibleMethods.contains(EventTypes.QRScan) || + knownVerificationMethods.contains(EventTypes.QRScan) && + possibleMethods.contains(EventTypes.QRShow); +} List _intersect(List? a, List? b) => (b == null || a == null) ? [] : a.where(b.contains).toList(); +List _calculatePossibleMethods( + List knownMethods, List payloadMethods) { + final output = []; + final copyKnownMethods = List.from(knownMethods); + final copyPayloadMethods = List.from(payloadMethods); + + copyKnownMethods + .removeWhere((element) => !copyPayloadMethods.contains(element)); + + // remove qr modes for now, check if they are possible and add later + copyKnownMethods.removeWhere((element) => element.startsWith('m.qr_code')); + output.addAll(copyKnownMethods); + + if (isQrSupported(knownMethods, payloadMethods)) { + // scan/show combo found, add whichever is known to us to our possible methods. + if (payloadMethods.contains(EventTypes.QRScan) && + knownMethods.contains(EventTypes.QRShow)) { + output.add(EventTypes.QRShow); + } + if (payloadMethods.contains(EventTypes.QRShow) && + knownMethods.contains(EventTypes.QRScan)) { + output.add(EventTypes.QRScan); + } + } else { + output.remove(EventTypes.Reciprocate); + } + + return output; +} + List _bytesToInt(Uint8List bytes, int totalBits) { final ret = []; var current = 0; @@ -96,9 +194,12 @@ List _bytesToInt(Uint8List bytes, int totalBits) { _KeyVerificationMethod _makeVerificationMethod( String type, KeyVerification request) { - if (type == 'm.sas.v1') { + if (type == EventTypes.Sas) { return _KeyVerificationMethodSas(request: request); } + if (type == EventTypes.Reciprocate) { + return _KeyVerificationMethodQRReciprocate(request: request); + } throw Exception('Unkown method type'); } @@ -113,7 +214,10 @@ class KeyVerification { String? _deviceId; bool startedVerification = false; _KeyVerificationMethod? _method; + List possibleMethods = []; + List oppositePossibleMethods = []; + Map? startPayload; String? _nextAction; List _verifiedDevices = []; @@ -129,6 +233,11 @@ class KeyVerification { canceled || {KeyVerificationState.error, KeyVerificationState.done}.contains(state); + String? chosenMethod; + // qr stuff + QRCode? qrCode; + String? randomSharedSecretForQRCode; + SignableKey? keyToVerify; KeyVerification( {required this.encryption, this.room, @@ -140,6 +249,7 @@ class KeyVerification { void dispose() { Logs().i('[Key Verification] disposing object...'); + randomSharedSecretForQRCode = null; _method?.dispose(); } @@ -151,15 +261,63 @@ class KeyVerification { } List get knownVerificationMethods { - final methods = []; + final methods = {}; if (client.verificationMethods.contains(KeyVerificationMethod.numbers) || client.verificationMethods.contains(KeyVerificationMethod.emoji)) { - methods.add('m.sas.v1'); + methods.add(EventTypes.Sas); } - return methods; + + /// `qrCanWork` - qr cannot work if we are verifying another master key but our own is unverified + final qrCanWork = (userId != client.userID) + ? ((client.userDeviceKeys[client.userID]?.masterKey?.verified ?? false) + ? true + : false) + : true; + + if (client.verificationMethods.contains(KeyVerificationMethod.qrShow) && + qrCanWork) { + methods.add(EventTypes.QRShow); + methods.add(EventTypes.Reciprocate); + } + if (client.verificationMethods.contains(KeyVerificationMethod.qrScan) && + qrCanWork) { + methods.add(EventTypes.QRScan); + methods.add(EventTypes.Reciprocate); + } + + return methods.toList(); } - Future sendStart() async { + /// Once you get a ready event, i.e both sides are in a `askChoice` state, + /// send either `m.reciprocate.v1` or `m.sas.v1` here. If you continue with + /// qr, send the qrData you just scanned + Future continueVerification(String type, + {Uint8List? qrDataRawBytes}) async { + chosenMethod = type; + bool qrChecksOut = false; + if (possibleMethods.contains(type)) { + if (qrDataRawBytes != null) { + qrChecksOut = await verifyQrData(qrDataRawBytes); + // after this scanners state is done + } + if (type != EventTypes.Reciprocate || qrChecksOut) { + final method = _method = _makeVerificationMethod(type, this); + await method.sendStart(); + if (type == EventTypes.Sas) { + setState(KeyVerificationState.waitingAccept); + } + } else if (type == EventTypes.Reciprocate && !qrChecksOut) { + Logs().e('[KeyVerification] qr did not check out'); + await cancel('m.invalid_key'); + } + } else { + Logs().e( + '[KeyVerification] tried to continue verification with a unknown method'); + await cancel('m.unknown_method'); + } + } + + Future sendRequest() async { await send( EventTypes.KeyVerificationRequest, { @@ -182,12 +340,27 @@ class KeyVerification { setState(KeyVerificationState.askSSSS); _nextAction = 'request'; } else { - await sendStart(); + await sendRequest(); } } bool _handlePayloadLock = false; + QRMode getOurQRMode() { + QRMode mode = QRMode.verifyOtherUser; + if (client.userID == userId) { + if (client.encryption != null && + client.encryption!.enabled && + (client.userDeviceKeys[client.userID]?.masterKey?.directVerified ?? + false)) { + mode = QRMode.verifySelfTrusted; + } else { + mode = QRMode.verifySelfUntrusted; + } + } + return mode; + } + Future handlePayload(String type, Map payload, [String? eventId]) async { if (isDone) { @@ -216,14 +389,6 @@ class KeyVerification { now.subtract(Duration(minutes: 20)).isAfter(verifyTime)); return; } - // verify it has a method we can use - possibleMethods = - _intersect(knownVerificationMethods, payload['methods']); - if (possibleMethods.isEmpty) { - // reject it outright - await cancel('m.unknown_method'); - return; - } // ensure we have the other sides keys if (client.userDeviceKeys[userId]?.deviceKeys[deviceId!] == null) { @@ -234,11 +399,22 @@ class KeyVerification { } } + oppositePossibleMethods = List.from(payload['methods']); + // verify it has a method we can use + possibleMethods = _calculatePossibleMethods( + knownVerificationMethods, payload['methods']); + if (possibleMethods.isEmpty) { + // reject it outright + await cancel('m.unknown_method'); + return; + } + setState(KeyVerificationState.askAccept); break; case 'm.key.verification.ready': if (deviceId == '*') { _deviceId = payload['from_device']; // gotta set the real device id + transactionId ??= eventId ?? payload['transaction_id']; // and broadcast the cancel to the other devices final devices = List.from( client.userDeviceKeys[userId]?.deviceKeys.values ?? @@ -254,13 +430,6 @@ class KeyVerification { devices, EventTypes.KeyVerificationCancel, cancelPayload); } _deviceId ??= payload['from_device']; - possibleMethods = - _intersect(knownVerificationMethods, payload['methods']); - if (possibleMethods.isEmpty) { - // reject it outright - await cancel('m.unknown_method'); - return; - } // ensure we have the other sides keys if (client.userDeviceKeys[userId]?.deviceKeys[deviceId!] == null) { @@ -271,14 +440,35 @@ class KeyVerification { } } + oppositePossibleMethods = List.from(payload['methods']); + possibleMethods = _calculatePossibleMethods( + knownVerificationMethods, payload['methods']); + if (possibleMethods.isEmpty) { + // reject it outright + await cancel('m.unknown_method'); + return; + } // as both parties can send a start, the last step being "ready" is race-condition prone // as such, we better set it *before* we send our start lastStep = type; - // TODO: Pick method? - final method = - _method = _makeVerificationMethod(possibleMethods.first, this); - await method.sendStart(); - setState(KeyVerificationState.waitingAccept); + + // setup QRData from outgoing request (incoming ready) + qrCode = await generateQrCode(); + + // play nice with sdks < 0.20.5 + // https://matrix.to/#/!KBwfdofYJUmnsVoqwn:famedly.de/$wlHXlLQJdfrqKAF5KkuQrXydwOhY_uyqfH4ReasZqnA?via=neko.dev&via=famedly.de&via=lihotzki.de + if (!isQrSupported(knownVerificationMethods, payload['methods'])) { + if (knownVerificationMethods.contains(EventTypes.Sas)) { + final method = _method = + _makeVerificationMethod(possibleMethods.first, this); + await method.sendStart(); + setState(KeyVerificationState.waitingAccept); + } + } else { + // allow user to choose + setState(KeyVerificationState.askChoice); + } + break; case EventTypes.KeyVerificationStart: _deviceId ??= payload['from_device']; @@ -306,8 +496,10 @@ class KeyVerification { return; } } - if (!(await verifyLastStep( - [EventTypes.KeyVerificationRequest, null]))) { + if (!(await verifyLastStep([ + EventTypes.KeyVerificationRequest, + 'm.key.verification.ready', + ]))) { return; // abort } if (!knownVerificationMethods.contains(payload['method'])) { @@ -315,6 +507,13 @@ class KeyVerification { return; } + if (lastStep == EventTypes.KeyVerificationRequest) { + if (!possibleMethods.contains(payload['method'])) { + await cancel('m.unknown_method'); + return; + } + } + // ensure we have the other sides keys if (client.userDeviceKeys[userId]?.deviceKeys[deviceId!] == null) { await client.updateUserDeviceKeys(additionalUsers: {userId}); @@ -345,7 +544,10 @@ class KeyVerification { } break; case EventTypes.KeyVerificationDone: - // do nothing + if (state == KeyVerificationState.showQRSuccess) { + await send(EventTypes.KeyVerificationDone, {}); + setState(KeyVerificationState.done); + } break; case EventTypes.KeyVerificationCancel: canceled = true; @@ -387,11 +589,13 @@ class KeyVerification { bool skip = false}) async { Future next() async { if (_nextAction == 'request') { - await sendStart(); + await sendRequest(); } else if (_nextAction == 'done') { // and now let's sign them all in the background unawaited(encryption.crossSigning.sign(_verifiedDevices)); setState(KeyVerificationState.done); + } else if (_nextAction == 'showQRSuccess') { + setState(KeyVerificationState.showQRSuccess); } } @@ -418,10 +622,32 @@ class KeyVerification { } setState(KeyVerificationState.waitingAccept); if (lastStep == EventTypes.KeyVerificationRequest) { + final copyKnownVerificationMethods = + List.from(knownVerificationMethods); + // qr code only works when atleast one side has verified master key + if (userId == client.userID) { + if (!(client.userDeviceKeys[client.userID]?.deviceKeys[deviceId] + ?.hasValidSignatureChain(verifiedByTheirMasterKey: true) ?? + false) && + !(client.userDeviceKeys[client.userID]?.masterKey?.verified ?? + false)) { + copyKnownVerificationMethods + .removeWhere((element) => element.startsWith('m.qr_code')); + copyKnownVerificationMethods.remove(EventTypes.Reciprocate); + + // we are removing stuff only using the old possibleMethods should be ok here. + final copyPossibleMethods = List.from(possibleMethods); + possibleMethods = _calculatePossibleMethods( + copyKnownVerificationMethods, copyPossibleMethods); + } + } // we need to send a ready event await send('m.key.verification.ready', { - 'methods': possibleMethods, + 'methods': copyKnownVerificationMethods, }); + // setup QRData from incoming request (outgoing ready) + qrCode = await generateQrCode(); + setState(KeyVerificationState.askChoice); } else { // we need to send an accept event await _method! @@ -443,6 +669,16 @@ class KeyVerification { await cancel('m.user'); } + /// call this to confirm that your other device has shown a shield and is in + /// `done` state. + Future acceptQRScanConfirmation() async { + if (_method is _KeyVerificationMethodQRReciprocate && + state == KeyVerificationState.confirmQRScan) { + await (_method as _KeyVerificationMethodQRReciprocate) + .acceptQRScanConfirmation(); + } + } + Future acceptSas() async { if (_method is _KeyVerificationMethodSas) { await (_method as _KeyVerificationMethodSas).acceptSas(); @@ -501,7 +737,7 @@ class KeyVerification { () => maybeRequestSSSSSecrets(i + 1)); } - Future verifyKeys(Map keys, + Future verifyKeysSAS(Map keys, Future Function(String, SignableKey) verifier) async { _verifiedDevices = []; @@ -561,6 +797,56 @@ class KeyVerification { } } + /// shower is true only for reciprocated verifications (shower side) + Future verifyKeysQR(SignableKey key, {bool shower = true}) async { + var verifiedMasterKey = false; + final wasUnknownSession = client.isUnknownSession; + + key.setDirectVerified(true); + if (key is CrossSigningKey && key.usage.contains('master')) { + verifiedMasterKey = true; + } + + if (verifiedMasterKey && userId == client.userID) { + // it was our own master key, let's request the cross signing keys + // we do it in the background, thus no await needed here + // ignore: unawaited_futures + maybeRequestSSSSSecrets(); + } + if (shower) { + await send(EventTypes.KeyVerificationDone, {}); + } + final keyList = List.from([key]); + var askingSSSS = false; + if (encryption.crossSigning.enabled && + encryption.crossSigning.signable(keyList)) { + // these keys can be signed! Let's do so + if (await encryption.crossSigning.isCached()) { + // we want to make sure the verification state is correct for the other party after this event is handled. + // Otherwise the verification dialog might be stuck in an unverified but done state for a bit. + await encryption.crossSigning.sign(keyList); + } else if (!wasUnknownSession) { + askingSSSS = true; + } + } + if (askingSSSS) { + // no need to worry about shower/scanner here because if scanner was + // verified, ssss is already + setState(KeyVerificationState.askSSSS); + if (shower) { + _nextAction = 'done'; + } else { + _nextAction = 'showQRSuccess'; + } + } else { + if (shower) { + setState(KeyVerificationState.done); + } else { + setState(KeyVerificationState.showQRSuccess); + } + } + } + Future verifyActivity() async { if (lastActivity.add(Duration(minutes: 10)).isAfter(DateTime.now())) { lastActivity = DateTime.now(); @@ -577,6 +863,8 @@ class KeyVerification { if (checkLastStep.contains(lastStep)) { return true; } + Logs().e( + '[KeyVerificaton] lastStep mismatch cancelling, expected from ${checkLastStep.toString()} was ${lastStep.toString()}'); await cancel('m.unexpected_message'); return false; } @@ -668,6 +956,138 @@ class KeyVerification { onUpdate?.call(); } + + static const String prefix = 'MATRIX'; + static const int version = 0x02; + + Future verifyQrData(Uint8List qrDataRawBytes) async { + final data = qrDataRawBytes; + // hardcoded stuff + 2 keys + secret + if (data.length < 10 + 32 + 32 + 8 + utf8.encode(transactionId!).length) { + return false; + } + if (data[6] != version) return false; + final remoteQrMode = + QRMode.values.singleWhere((mode) => mode.code == data[7]); + if (ascii.decode(data.sublist(0, 6)) != prefix) return false; + if (data[6] != version) return false; + final tmpBuf = Uint8List.fromList([data[8], data[9]]); + final encodedTxnLen = ByteData.view(tmpBuf.buffer).getUint16(0); + if (utf8.decode(data.sublist(10, 10 + encodedTxnLen)) != transactionId) { + return false; + } + final keys = client.userDeviceKeys; + + final ownKeys = keys[client.userID]; + final otherUserKeys = keys[userId]; + final ownMasterKey = ownKeys?.getCrossSigningKey('master'); + final ownDeviceKey = ownKeys?.getKey(client.deviceID!); + final ownOtherDeviceKey = ownKeys?.getKey(deviceId!); + final otherUserMasterKey = otherUserKeys?.masterKey; + + final secondKey = encodeBase64Unpadded( + data.sublist(10 + encodedTxnLen + 32, 10 + encodedTxnLen + 32 + 32)); + final randomSharedSecret = + encodeBase64Unpadded(data.sublist(10 + encodedTxnLen + 32 + 32)); + + /// `request.randomSharedSecretForQRCode` is overwritten below to send with `sendStart` + if ({QRMode.verifyOtherUser, QRMode.verifySelfUntrusted} + .contains(remoteQrMode)) { + if (!(ownMasterKey?.verified ?? false)) { + Logs().e( + '[KeyVerification] verifyQrData because you were in mode 0/2 and had untrusted msk'); + return false; + } + } + + if (remoteQrMode == QRMode.verifyOtherUser && + otherUserMasterKey != null && + ownMasterKey != null) { + if (secondKey == ownMasterKey.ed25519Key) { + randomSharedSecretForQRCode = randomSharedSecret; + await verifyKeysQR(otherUserMasterKey, shower: false); + return true; + } + } else if (remoteQrMode == QRMode.verifySelfTrusted && + ownMasterKey != null && + ownDeviceKey != null) { + if (secondKey == ownDeviceKey.ed25519Key) { + randomSharedSecretForQRCode = randomSharedSecret; + await verifyKeysQR(ownMasterKey, shower: false); + return true; + } + } else if (remoteQrMode == QRMode.verifySelfUntrusted && + ownOtherDeviceKey != null && + ownMasterKey != null) { + if (secondKey == ownMasterKey.ed25519Key) { + randomSharedSecretForQRCode = randomSharedSecret; + await verifyKeysQR(ownOtherDeviceKey, shower: false); + return true; + } + } + + return false; + } + + Future<(String, String)?> getKeys(QRMode mode) async { + final keys = client.userDeviceKeys; + + final ownKeys = keys[client.userID]; + final otherUserKeys = keys[userId]; + final ownDeviceKey = ownKeys?.getKey(client.deviceID!); + final ownMasterKey = ownKeys?.getCrossSigningKey('master'); + final otherDeviceKey = otherUserKeys?.getKey(deviceId!); + final otherMasterKey = otherUserKeys?.getCrossSigningKey('master'); + + if (mode == QRMode.verifyOtherUser && + ownMasterKey != null && + otherMasterKey != null) { + // we already have this check when sending `knownVerificationMethods`, but + // just to be safe anyway + if (ownMasterKey.verified) { + return (ownMasterKey.ed25519Key!, otherMasterKey.ed25519Key!); + } + } else if (mode == QRMode.verifySelfTrusted && + ownMasterKey != null && + otherDeviceKey != null) { + if (ownMasterKey.verified) { + return (ownMasterKey.ed25519Key!, otherDeviceKey.ed25519Key!); + } + } else if (mode == QRMode.verifySelfUntrusted && + ownMasterKey != null && + ownDeviceKey != null) { + return (ownDeviceKey.ed25519Key!, ownMasterKey.ed25519Key!); + } + return null; + } + + Future generateQrCode() async { + final data = Uint8Buffer(); + // why 11? https://github.com/matrix-org/matrix-js-sdk/commit/275ea6aacbfc6623e7559a7649ca5cab207903d9 + randomSharedSecretForQRCode = + encodeBase64Unpadded(uc.secureRandomBytes(11)); + + final mode = getOurQRMode(); + data.addAll(ascii.encode(prefix)); + data.add(version); + data.add(mode.code); + final encodedTxnId = utf8.encode(transactionId!); + final txnIdLen = encodedTxnId.length; + final tmpBuf = Uint8List(2); + ByteData.view(tmpBuf.buffer).setUint16(0, txnIdLen); + data.addAll(tmpBuf); + data.addAll(encodedTxnId); + final keys = await getKeys(mode); + if (keys != null) { + data.addAll(base64decodeUnpadded(keys.$1)); + data.addAll(base64decodeUnpadded(keys.$2)); + } else { + return null; + } + + data.addAll(base64decodeUnpadded(randomSharedSecretForQRCode!)); + return QRCode(randomSharedSecretForQRCode!, data); + } } abstract class _KeyVerificationMethod { @@ -688,6 +1108,99 @@ abstract class _KeyVerificationMethod { void dispose() {} } +class _KeyVerificationMethodQRReciprocate extends _KeyVerificationMethod { + _KeyVerificationMethodQRReciprocate({required super.request}); + + @override + // ignore: overridden_fields + final _type = EventTypes.Reciprocate; + + @override + bool validateStart(Map payload) { + if (payload['method'] != type) return false; + if (payload['secret'] != request.randomSharedSecretForQRCode) return false; + return true; + } + + @override + Future handlePayload(String type, Map payload) async { + try { + switch (type) { + case EventTypes.KeyVerificationStart: + if (!(await request.verifyLastStep([ + 'm.key.verification.ready', + EventTypes.KeyVerificationRequest, + ]))) { + return; // abort + } + if (!validateStart(payload)) { + await request.cancel('m.invalid_message'); + return; + } + request.setState(KeyVerificationState.confirmQRScan); + break; + } + } catch (e, s) { + Logs().e('[Key Verification Reciprocate] An error occured', e, s); + if (request.deviceId != null) { + await request.cancel('m.invalid_message'); + } + } + } + + Future acceptQRScanConfirmation() async { + // secret validation already done in validateStart + + final ourQRMode = request.getOurQRMode(); + SignableKey? keyToVerify; + + if (ourQRMode == QRMode.verifyOtherUser) { + keyToVerify = client.userDeviceKeys[request.userId]?.masterKey; + } else if (ourQRMode == QRMode.verifySelfTrusted) { + keyToVerify = + client.userDeviceKeys[client.userID]?.deviceKeys[request.deviceId]; + } else if (ourQRMode == QRMode.verifySelfUntrusted) { + keyToVerify = client.userDeviceKeys[client.userID]?.masterKey; + } + if (keyToVerify != null) { + await request.verifyKeysQR(keyToVerify, shower: true); + } else { + Logs().e('[KeyVerification], verifying keys failed'); + await request.cancel('m.invalid_key'); + } + } + + @override + Future sendStart() async { + final payload = { + 'method': type, + 'secret': request.randomSharedSecretForQRCode, + }; + request.makePayload(payload); + await request.send(EventTypes.KeyVerificationStart, payload); + } + + @override + void dispose() {} +} + +enum QRMode { + verifyOtherUser(0x00), + verifySelfTrusted(0x01), + verifySelfUntrusted(0x02); + + const QRMode(this.code); + final int code; +} + +class QRCode { + /// You actually never need this when implementing in a client, its just to + /// make tests easier. Just pass `qrDataRawBytes` in `continueVerifcation()` + final String randomSharedSecret; + final Uint8Buffer qrDataRawBytes; + QRCode(this.randomSharedSecret, this.qrDataRawBytes); +} + const knownKeyAgreementProtocols = ['curve25519-hkdf-sha256', 'curve25519']; const knownHashes = ['sha256']; const knownHashesAuthentificationCodes = ['hkdf-hmac-sha256']; @@ -698,7 +1211,7 @@ class _KeyVerificationMethodSas extends _KeyVerificationMethod { @override // ignore: overridden_fields - final _type = 'm.sas.v1'; + final _type = EventTypes.Sas; String? keyAgreementProtocol; String? hash; @@ -734,6 +1247,7 @@ class _KeyVerificationMethodSas extends _KeyVerificationMethod { switch (type) { case EventTypes.KeyVerificationStart: if (!(await request.verifyLastStep([ + 'm.key.verification.ready', EventTypes.KeyVerificationRequest, EventTypes.KeyVerificationStart ]))) { @@ -746,7 +1260,10 @@ class _KeyVerificationMethodSas extends _KeyVerificationMethod { await _sendAccept(); break; case EventTypes.KeyVerificationAccept: - if (!(await request.verifyLastStep(['m.key.verification.ready']))) { + if (!(await request.verifyLastStep([ + 'm.key.verification.ready', + EventTypes.KeyVerificationRequest + ]))) { return; } if (!_handleAccept(payload)) { @@ -982,7 +1499,7 @@ class _KeyVerificationMethodSas extends _KeyVerificationMethod { mac[entry.key] = entry.value; } } - await request.verifyKeys(mac, (String mac, SignableKey key) async { + await request.verifyKeysSAS(mac, (String mac, SignableKey key) async { return mac == _calculateMac( key.ed25519Key!, '${baseInfo}ed25519:${key.identifier!}'); diff --git a/pubspec.yaml b/pubspec.yaml index d31305f6..89900a30 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -5,7 +5,7 @@ homepage: https://famedly.com repository: https://gitlab.com/famedly/company/frontend/famedlysdk.git environment: - sdk: ">=2.18.0 <4.0.0" + sdk: ">=3.0.0 <4.0.0" dependencies: async: ^2.8.0 @@ -28,6 +28,7 @@ dependencies: random_string: ^2.3.1 sdp_transform: ^0.3.2 slugify: ^2.0.0 + typed_data: ^1.3.2 webrtc_interface: ^1.0.13 dev_dependencies: diff --git a/test/encryption/key_verification_test.dart b/test/encryption/key_verification_test.dart index d6dc9aa0..d89937da 100644 --- a/test/encryption/key_verification_test.dart +++ b/test/encryption/key_verification_test.dart @@ -18,6 +18,7 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:typed_data'; import 'package:olm/olm.dart' as olm; import 'package:test/test.dart'; @@ -28,19 +29,6 @@ import '../fake_client.dart'; import '../fake_database.dart'; import '../fake_matrix_api.dart'; -class MockSSSS extends SSSS { - MockSSSS(Encryption encryption) : super(encryption); - - bool requestedSecrets = false; - @override - Future maybeRequestAll([List? devices]) async { - requestedSecrets = true; - final handle = open(); - await handle.unlock(recoveryKey: ssssKey); - await handle.maybeCacheAll(); - } -} - EventUpdate getLastSentEvent(KeyVerification req) { final entry = FakeMatrixApi.calledEndpoints.entries .firstWhere((p) => p.key.contains('/send/')); @@ -82,7 +70,6 @@ void main() async { late Client client1; late Client client2; - setUp(() async { client1 = await getClient(); client2 = Client( @@ -103,11 +90,16 @@ void main() async { await Future.delayed(Duration(milliseconds: 10)); client1.verificationMethods = { KeyVerificationMethod.emoji, - KeyVerificationMethod.numbers + KeyVerificationMethod.numbers, + KeyVerificationMethod.qrScan, + KeyVerificationMethod.qrShow, + KeyVerificationMethod.reciprocate }; client2.verificationMethods = { KeyVerificationMethod.emoji, - KeyVerificationMethod.numbers + KeyVerificationMethod.numbers, + KeyVerificationMethod.qrShow, + KeyVerificationMethod.reciprocate }; }); tearDown(() async { @@ -143,18 +135,24 @@ void main() async { client2.encryption!.keyVerificationManager .getRequest(req2.transactionId!), req2); - // send ready FakeMatrixApi.calledEndpoints.clear(); await req2.acceptVerification(); await FakeMatrixApi.firstWhere((e) => e.startsWith( '/client/v3/rooms/!1234%3AfakeServer.notExisting/send/m.key.verification.ready')); - evt = getLastSentEvent(req2); - expect(req2.state, KeyVerificationState.waitingAccept); - // send start + evt = getLastSentEvent(req2); + expect(req2.state, KeyVerificationState.askChoice); + FakeMatrixApi.calledEndpoints.clear(); + await client1.encryption!.keyVerificationManager.handleEventUpdate(evt); + + expect(req1.possibleMethods, [EventTypes.Sas]); + expect(req2.possibleMethods, [EventTypes.Sas]); + expect(req1.state, KeyVerificationState.waitingAccept); + + // no need for start (continueVerification) because sas only mode override already sent it after ready await FakeMatrixApi.firstWhere((e) => e.startsWith( '/client/v3/rooms/!1234%3AfakeServer.notExisting/send/m.key.verification.start')); evt = getLastSentEvent(req1); @@ -288,11 +286,16 @@ void main() async { await FakeMatrixApi.firstWhere((e) => e.startsWith( '/client/v3/rooms/!1234%3AfakeServer.notExisting/send/m.key.verification.ready')); evt = getLastSentEvent(req2); - expect(req2.state, KeyVerificationState.waitingAccept); - - // send start + expect(req2.state, KeyVerificationState.askChoice); FakeMatrixApi.calledEndpoints.clear(); + await client1.encryption!.keyVerificationManager.handleEventUpdate(evt); + + expect(req1.possibleMethods, [EventTypes.Sas]); + expect(req2.possibleMethods, [EventTypes.Sas]); + expect(req1.state, KeyVerificationState.waitingAccept); + + // no need for start (continueVerification) because sas only mode override already sent it after ready await FakeMatrixApi.firstWhere((e) => e.startsWith( '/client/v3/rooms/!1234%3AfakeServer.notExisting/send/m.key.verification.start')); evt = getLastSentEvent(req1); @@ -370,13 +373,8 @@ void main() async { await req1.openSSSS(recoveryKey: ssssKey); expect(req1.state, KeyVerificationState.done); - client1.encryption!.ssss = MockSSSS(client1.encryption!); - (client1.encryption!.ssss as MockSSSS).requestedSecrets = false; - await client1.encryption!.ssss.clearCache(); - await req1.maybeRequestSSSSSecrets(); - await Future.delayed(Duration(milliseconds: 10)); - expect((client1.encryption!.ssss as MockSSSS).requestedSecrets, true); - + // let any background box usage from ssss signing finish + await Future.delayed(Duration(seconds: 1)); await client1.encryption!.keyVerificationManager.cleanup(); await client2.encryption!.keyVerificationManager.cleanup(); }); @@ -440,11 +438,17 @@ void main() async { await FakeMatrixApi.firstWhere((e) => e.startsWith( '/client/v3/rooms/!1234%3AfakeServer.notExisting/send/m.key.verification.ready')); evt = getLastSentEvent(req2); - expect(req2.state, KeyVerificationState.waitingAccept); - - // send start + expect(req2.state, KeyVerificationState.askChoice); FakeMatrixApi.calledEndpoints.clear(); + await client1.encryption!.keyVerificationManager.handleEventUpdate(evt); + + expect(req1.possibleMethods, [EventTypes.Sas]); + expect(req2.possibleMethods, [EventTypes.Sas]); + + expect(req1.state, KeyVerificationState.waitingAccept); + + // no need for start (continueVerification) because sas only mode override already sent it after ready await FakeMatrixApi.firstWhere((e) => e.startsWith( '/client/v3/rooms/!1234%3AfakeServer.notExisting/send/m.key.verification.start')); evt = getLastSentEvent(req1); @@ -516,7 +520,7 @@ void main() async { 'event_id': req2.transactionId, 'type': 'm.key.verification.ready', 'content': { - 'methods': ['m.sas.v1'], + 'methods': [EventTypes.Sas], 'from_device': 'SOMEOTHERDEVICE', 'm.relates_to': { 'rel_type': 'm.reference', @@ -537,5 +541,274 @@ void main() async { await client1.encryption!.keyVerificationManager.cleanup(); await client2.encryption!.keyVerificationManager.cleanup(); }); + + test('Run qr verification mode 0, ssss start', () async { + expect(client1.userDeviceKeys[client2.userID]?.masterKey!.directVerified, + false); + expect(client2.userDeviceKeys[client1.userID]?.masterKey!.directVerified, + false); + // for a full run we test in-room verification in a cleartext room + // because then we can easily intercept the payloads and inject in the other client + FakeMatrixApi.calledEndpoints.clear(); + // make sure our master key is *not* verified to not triger SSSS for now + client1.userDeviceKeys[client1.userID]!.masterKey! + .setDirectVerified(true); + client2.userDeviceKeys[client2.userID]!.masterKey! + .setDirectVerified(true); + await client1.encryption!.ssss.clearCache(); + + final req1 = + await client1.userDeviceKeys[client2.userID]!.startVerification( + newDirectChatEnableEncryption: false, + ); + + expect(req1.state, KeyVerificationState.askSSSS); + await req1.openSSSS(recoveryKey: ssssKey); + + await FakeMatrixApi.firstWhere((e) => e.startsWith( + '/client/v3/rooms/!1234%3AfakeServer.notExisting/send/m.room.message')); + + var evt = getLastSentEvent(req1); + expect(req1.state, KeyVerificationState.waitingAccept); + + final comp = Completer(); + final sub = client2.onKeyVerificationRequest.stream.listen((req) { + comp.complete(req); + }); + await client2.encryption!.keyVerificationManager.handleEventUpdate(evt); + final req2 = await comp.future; + await sub.cancel(); + + expect( + client2.encryption!.keyVerificationManager + .getRequest(req2.transactionId!), + req2); + + // send ready + FakeMatrixApi.calledEndpoints.clear(); + await req2.acceptVerification(); + await FakeMatrixApi.firstWhere((e) => e.startsWith( + '/client/v3/rooms/!1234%3AfakeServer.notExisting/send/m.key.verification.ready')); + evt = getLastSentEvent(req2); + expect(req2.state, KeyVerificationState.askChoice); + await client1.encryption!.keyVerificationManager.handleEventUpdate(evt); + expect(req1.state, KeyVerificationState.askChoice); + expect(req1.possibleMethods, + [EventTypes.Sas, EventTypes.Reciprocate, EventTypes.QRScan]); + expect(req2.possibleMethods, + [EventTypes.Sas, EventTypes.Reciprocate, EventTypes.QRShow]); + + // send start + FakeMatrixApi.calledEndpoints.clear(); + await req1.continueVerification(EventTypes.Reciprocate, + qrDataRawBytes: + Uint8List.fromList(req2.qrCode?.qrDataRawBytes ?? [])); + expect(req2.qrCode!.randomSharedSecret, req1.randomSharedSecretForQRCode); + expect(req1.state, KeyVerificationState.showQRSuccess); + await FakeMatrixApi.firstWhere((e) => e.startsWith( + '/client/v3/rooms/!1234%3AfakeServer.notExisting/send/m.key.verification.start')); + evt = getLastSentEvent(req1); + + // send done + FakeMatrixApi.calledEndpoints.clear(); + await client2.encryption!.keyVerificationManager.handleEventUpdate(evt); + expect(req2.state, KeyVerificationState.confirmQRScan); + await req2.acceptQRScanConfirmation(); + await FakeMatrixApi.firstWhere((e) => e.startsWith( + '/client/v3/rooms/!1234%3AfakeServer.notExisting/send/m.key.verification.done')); + evt = getLastSentEvent(req2); + + FakeMatrixApi.calledEndpoints.clear(); + await client1.encryption!.keyVerificationManager.handleEventUpdate(evt); + + expect(req1.state, KeyVerificationState.done); + expect(req2.state, KeyVerificationState.done); + + expect(client1.userDeviceKeys[client2.userID]?.masterKey!.directVerified, + true); + expect(client2.userDeviceKeys[client1.userID]?.masterKey!.directVerified, + true); + + await client1.encryption!.keyVerificationManager.cleanup(); + await client2.encryption!.keyVerificationManager.cleanup(); + }); + + test('Run qr verification mode 0, but fail on masterKey unverified client1', + () async { + // for a full run we test in-room verification in a cleartext room + // because then we can easily intercept the payloads and inject in the other client + FakeMatrixApi.calledEndpoints.clear(); + // make sure our master key is *not* verified to not triger SSSS for now + client1.userDeviceKeys[client1.userID]!.masterKey! + .setDirectVerified(false); + + final req1 = + await client1.userDeviceKeys[client2.userID]!.startVerification( + newDirectChatEnableEncryption: false, + ); + await FakeMatrixApi.firstWhere((e) => e.startsWith( + '/client/v3/rooms/!1234%3AfakeServer.notExisting/send/m.room.message')); + + var evt = getLastSentEvent(req1); + expect(req1.state, KeyVerificationState.waitingAccept); + + final comp = Completer(); + final sub = client2.onKeyVerificationRequest.stream.listen((req) { + comp.complete(req); + }); + await client2.encryption!.keyVerificationManager.handleEventUpdate(evt); + final req2 = await comp.future; + await sub.cancel(); + + expect( + client2.encryption!.keyVerificationManager + .getRequest(req2.transactionId!), + req2); + // send ready + FakeMatrixApi.calledEndpoints.clear(); + await req2.acceptVerification(); + await FakeMatrixApi.firstWhere((e) => e.startsWith( + '/client/v3/rooms/!1234%3AfakeServer.notExisting/send/m.key.verification.ready')); + evt = getLastSentEvent(req2); + expect(req2.state, KeyVerificationState.askChoice); + await client1.encryption!.keyVerificationManager.handleEventUpdate(evt); + + expect(req1.possibleMethods, [EventTypes.Sas]); + expect(req2.possibleMethods, [EventTypes.Sas]); + + expect(req1.state, KeyVerificationState.waitingAccept); + + // send start + FakeMatrixApi.calledEndpoints.clear(); + // qrCode will be null here anyway because masterKey not signed + await req1.continueVerification(EventTypes.Reciprocate, + qrDataRawBytes: + Uint8List.fromList(req2.qrCode?.qrDataRawBytes ?? [])); + expect(req1.state, KeyVerificationState.error); + + await client1.encryption!.keyVerificationManager.cleanup(); + await client2.encryption!.keyVerificationManager.cleanup(); + }); + + test('Run qr verification mode 0, but fail on masterKey unverified client2', + () async { + // for a full run we test in-room verification in a cleartext room + // because then we can easily intercept the payloads and inject in the other client + FakeMatrixApi.calledEndpoints.clear(); + // make sure our master key is *not* verified to not triger SSSS for now + client1.userDeviceKeys[client1.userID]!.masterKey! + .setDirectVerified(false); + + final req1 = + await client1.userDeviceKeys[client2.userID]!.startVerification( + newDirectChatEnableEncryption: false, + ); + await FakeMatrixApi.firstWhere((e) => e.startsWith( + '/client/v3/rooms/!1234%3AfakeServer.notExisting/send/m.room.message')); + + var evt = getLastSentEvent(req1); + expect(req1.state, KeyVerificationState.waitingAccept); + + final comp = Completer(); + final sub = client2.onKeyVerificationRequest.stream.listen((req) { + comp.complete(req); + }); + await client2.encryption!.keyVerificationManager.handleEventUpdate(evt); + final req2 = await comp.future; + await sub.cancel(); + + expect( + client2.encryption!.keyVerificationManager + .getRequest(req2.transactionId!), + req2); + // send ready + FakeMatrixApi.calledEndpoints.clear(); + await req2.acceptVerification(); + await FakeMatrixApi.firstWhere((e) => e.startsWith( + '/client/v3/rooms/!1234%3AfakeServer.notExisting/send/m.key.verification.ready')); + evt = getLastSentEvent(req2); + expect(req2.state, KeyVerificationState.askChoice); + await client1.encryption!.keyVerificationManager.handleEventUpdate(evt); + + expect(req1.possibleMethods, [EventTypes.Sas]); + expect(req2.possibleMethods, [EventTypes.Sas]); + + expect(req1.state, KeyVerificationState.waitingAccept); + FakeMatrixApi.calledEndpoints.clear(); + // qrCode will be null here anyway because masterKey not signed + await req2.continueVerification(EventTypes.Reciprocate, + qrDataRawBytes: + Uint8List.fromList(req2.qrCode?.qrDataRawBytes ?? [])); + expect(req2.state, KeyVerificationState.error); + + await client1.encryption!.keyVerificationManager.cleanup(); + await client2.encryption!.keyVerificationManager.cleanup(); + }); + + test( + 'Run qr verification mode, but fail because no knownVerificationMethod', + () async { + client1.verificationMethods = { + KeyVerificationMethod.emoji, + KeyVerificationMethod.numbers + }; + client2.verificationMethods = { + KeyVerificationMethod.emoji, + KeyVerificationMethod.numbers + }; + + // for a full run we test in-room verification in a cleartext room + // because then we can easily intercept the payloads and inject in the other client + FakeMatrixApi.calledEndpoints.clear(); + // make sure our master key is *not* verified to not triger SSSS for now + client1.userDeviceKeys[client1.userID]!.masterKey! + .setDirectVerified(false); + + final req1 = + await client1.userDeviceKeys[client2.userID]!.startVerification( + newDirectChatEnableEncryption: false, + ); + await FakeMatrixApi.firstWhere((e) => e.startsWith( + '/client/v3/rooms/!1234%3AfakeServer.notExisting/send/m.room.message')); + + var evt = getLastSentEvent(req1); + expect(req1.state, KeyVerificationState.waitingAccept); + + final comp = Completer(); + final sub = client2.onKeyVerificationRequest.stream.listen((req) { + comp.complete(req); + }); + await client2.encryption!.keyVerificationManager.handleEventUpdate(evt); + final req2 = await comp.future; + await sub.cancel(); + + expect( + client2.encryption!.keyVerificationManager + .getRequest(req2.transactionId!), + req2); + // send ready + FakeMatrixApi.calledEndpoints.clear(); + await req2.acceptVerification(); + await FakeMatrixApi.firstWhere((e) => e.startsWith( + '/client/v3/rooms/!1234%3AfakeServer.notExisting/send/m.key.verification.ready')); + evt = getLastSentEvent(req2); + expect(req2.state, KeyVerificationState.askChoice); + await client1.encryption!.keyVerificationManager.handleEventUpdate(evt); + + expect(req1.possibleMethods, [EventTypes.Sas]); + expect(req2.possibleMethods, [EventTypes.Sas]); + + expect(req1.state, KeyVerificationState.waitingAccept); + FakeMatrixApi.calledEndpoints.clear(); + + // qrCode will be null here anyway because qr isn't supported + await req1.continueVerification(EventTypes.Reciprocate, + qrDataRawBytes: + Uint8List.fromList(req2.qrCode?.qrDataRawBytes ?? [])); + expect(req1.state, KeyVerificationState.error); + + await client1.encryption!.keyVerificationManager.cleanup(); + await client2.encryption!.keyVerificationManager.cleanup(); + }); }, skip: skip); } diff --git a/test/encryption/qr_verification_self_test.dart b/test/encryption/qr_verification_self_test.dart new file mode 100644 index 00000000..3897a1b6 --- /dev/null +++ b/test/encryption/qr_verification_self_test.dart @@ -0,0 +1,536 @@ +/* + * Famedly Matrix SDK + * Copyright (C) 2020 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:async'; +import 'dart:typed_data'; + +import 'package:olm/olm.dart' as olm; +import 'package:test/test.dart'; + +import 'package:matrix/encryption.dart'; +import 'package:matrix/matrix.dart'; +import '../fake_client.dart'; + +void main() async { + // need to mock to pass correct data to handleToDeviceEvent + Future ingestCorrectReadyEvent( + KeyVerification req1, KeyVerification req2) async { + final copyKnownVerificationMethods = + List.from(req2.knownVerificationMethods); + + // this is the same logic from `acceptVerification()` just couldn't find a + // easy to to mock it + // qr code only works when atleast one side has verified master key + if (req2.userId == req2.client.userID) { + if (!(req2.client.userDeviceKeys[req2.client.userID] + ?.deviceKeys[req2.deviceId] + ?.hasValidSignatureChain(verifiedByTheirMasterKey: true) ?? + false) && + !(req2.client.userDeviceKeys[req2.client.userID]?.masterKey + ?.verified ?? + false)) { + copyKnownVerificationMethods + .removeWhere((element) => element.startsWith('m.qr_code')); + copyKnownVerificationMethods.remove(EventTypes.Reciprocate); + } + } + await req1.client.encryption!.keyVerificationManager.handleToDeviceEvent( + ToDeviceEvent( + type: 'm.key.verification.ready', + sender: req2.client.userID!, + content: { + 'from_device': req2.client.deviceID, + 'methods': copyKnownVerificationMethods, + 'transaction_id': req2.transactionId + }, + ), + ); + } + + var olmEnabled = true; + 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'); + + final dynamic skip = olmEnabled ? false : 'olm library not available'; + + /// All Tests related to the ChatTime + group('Key Verification', () { + Logs().level = Level.error; + + late Client client1; + late Client client2; + setUp(() async { + client1 = await getClient(); + client2 = await getOtherClient(); + + await Future.delayed(Duration(milliseconds: 10)); + client1.verificationMethods = { + KeyVerificationMethod.emoji, + KeyVerificationMethod.numbers, + KeyVerificationMethod.qrScan, + KeyVerificationMethod.reciprocate + }; + client2.verificationMethods = { + KeyVerificationMethod.emoji, + KeyVerificationMethod.numbers, + KeyVerificationMethod.qrShow, + KeyVerificationMethod.reciprocate + }; + }); + tearDown(() async { + await client1.dispose(closeDatabase: true); + await client2.dispose(closeDatabase: true); + }); + + test('Run qr verification mode 1', () async { + expect( + client1.userDeviceKeys[client2.userID]?.masterKey!.verified, false); + expect( + client2.userDeviceKeys[client1.userID]?.masterKey!.verified, false); + expect( + client1.userDeviceKeys[client2.userID]?.deviceKeys[client2.deviceID] + ?.verified, + false); + expect( + client2.userDeviceKeys[client1.userID]?.deviceKeys[client1.deviceID] + ?.verified, + false); + // make sure our master key is *not* verified to not triger SSSS for now + client1.userDeviceKeys[client1.userID]!.masterKey! + .setDirectVerified(true); + client2.userDeviceKeys[client2.userID]!.masterKey! + .setDirectVerified(false); + + await client1.encryption!.ssss.clearCache(); + final req1 = await client1.userDeviceKeys[client2.userID]! + .startVerification(newDirectChatEnableEncryption: false); + + expect(req1.state, KeyVerificationState.askSSSS); + await req1.openSSSS(recoveryKey: ssssKey); + + expect(req1.state, KeyVerificationState.waitingAccept); + + final comp = Completer(); + final sub = client2.onKeyVerificationRequest.stream.listen((req) { + comp.complete(req); + }); + await client2.encryption!.keyVerificationManager.handleToDeviceEvent( + ToDeviceEvent( + type: EventTypes.KeyVerificationRequest, + sender: req1.client.userID!, + content: { + 'from_device': req1.client.deviceID, + 'methods': req1.knownVerificationMethods, + 'timestamp': DateTime.now().millisecondsSinceEpoch, + 'transaction_id': req1.transactionId + }, + ), + ); + + final req2 = await comp.future; + await sub.cancel(); + + expect( + client2.encryption!.keyVerificationManager + .getRequest(req2.transactionId!), + req2); + + expect(req1.possibleMethods, []); + await req2.acceptVerification(); + + expect(req2.state, KeyVerificationState.askChoice); + await ingestCorrectReadyEvent(req1, req2); + expect(req1.possibleMethods, + [EventTypes.Sas, EventTypes.Reciprocate, EventTypes.QRScan]); + expect(req2.possibleMethods, + [EventTypes.Sas, EventTypes.Reciprocate, EventTypes.QRShow]); + + expect(req1.state, KeyVerificationState.askChoice); + + expect(req1.getOurQRMode(), QRMode.verifySelfTrusted); + expect(req2.getOurQRMode(), QRMode.verifySelfUntrusted); + + // send start + await req1.continueVerification(EventTypes.Reciprocate, + qrDataRawBytes: + Uint8List.fromList(req2.qrCode?.qrDataRawBytes ?? [])); + + expect(req2.qrCode!.randomSharedSecret, req1.randomSharedSecretForQRCode); + expect(req1.state, KeyVerificationState.showQRSuccess); + + await client2.encryption!.keyVerificationManager.handleToDeviceEvent( + ToDeviceEvent( + type: 'm.key.verification.start', + sender: req1.client.userID!, + content: { + 'from_device': req1.client.deviceID, + 'm.relates_to': { + 'event_id': req1.transactionId, + 'rel_type': 'm.reference' + }, + 'method': EventTypes.Reciprocate, + 'secret': req1.randomSharedSecretForQRCode, + 'transaction_id': req1.transactionId, + }, + ), + ); + + expect(req2.state, KeyVerificationState.confirmQRScan); + + await req2.acceptQRScanConfirmation(); + + await client1.encryption!.keyVerificationManager.handleToDeviceEvent( + ToDeviceEvent( + type: 'm.key.verification.done', + sender: req2.client.userID!, + content: { + 'transaction_id': req2.transactionId, + }, + ), + ); + + expect(req1.state, KeyVerificationState.done); + expect(req2.state, KeyVerificationState.done); + + expect(client1.userDeviceKeys[client2.userID]?.masterKey!.verified, true); + expect(client2.userDeviceKeys[client1.userID]?.masterKey!.verified, true); + + expect( + client1.userDeviceKeys[client2.userID]?.deviceKeys[client2.deviceID] + ?.verified, + true); + expect( + client2.userDeviceKeys[client1.userID]?.deviceKeys[client1.deviceID] + ?.verified, + true); + + // let any background box usage from ssss signing finish + await Future.delayed(Duration(seconds: 1)); + await client1.encryption!.keyVerificationManager.cleanup(); + await client2.encryption!.keyVerificationManager.cleanup(); + }); + + test('Run qr verification mode 2', () async { + expect( + client1.userDeviceKeys[client2.userID]?.masterKey!.verified, false); + expect( + client2.userDeviceKeys[client1.userID]?.masterKey!.verified, false); + expect( + client1.userDeviceKeys[client2.userID]?.deviceKeys[client2.deviceID] + ?.verified, + false); + expect( + client2.userDeviceKeys[client1.userID]?.deviceKeys[client1.deviceID] + ?.verified, + false); + // make sure our master key is *not* verified to not triger SSSS for now + client1.userDeviceKeys[client1.userID]!.masterKey! + .setDirectVerified(false); + client2.userDeviceKeys[client2.userID]!.masterKey! + .setDirectVerified(true); + // await client1.encryption!.ssss.clearCache(); + final req1 = + await client1.userDeviceKeys[client2.userID]!.startVerification( + newDirectChatEnableEncryption: false, + ); + + expect(req1.state, KeyVerificationState.waitingAccept); + + final comp = Completer(); + final sub = client2.onKeyVerificationRequest.stream.listen((req) { + comp.complete(req); + }); + await client2.encryption!.keyVerificationManager.handleToDeviceEvent( + ToDeviceEvent( + type: EventTypes.KeyVerificationRequest, + sender: req1.client.userID!, + content: { + 'from_device': req1.client.deviceID, + 'methods': req1.knownVerificationMethods, + 'timestamp': DateTime.now().millisecondsSinceEpoch, + 'transaction_id': req1.transactionId + }, + ), + ); + + final req2 = await comp.future; + await sub.cancel(); + + expect( + client2.encryption!.keyVerificationManager + .getRequest(req2.transactionId!), + req2); + + expect(req1.possibleMethods, []); + await req2.acceptVerification(); + + expect(req2.state, KeyVerificationState.askChoice); + await ingestCorrectReadyEvent(req1, req2); + + expect(req1.possibleMethods, + [EventTypes.Sas, EventTypes.Reciprocate, EventTypes.QRScan]); + expect(req2.possibleMethods, + [EventTypes.Sas, EventTypes.Reciprocate, EventTypes.QRShow]); + + expect(req1.state, KeyVerificationState.askChoice); + + expect(req1.getOurQRMode(), QRMode.verifySelfUntrusted); + expect(req2.getOurQRMode(), QRMode.verifySelfTrusted); + + // send start + await req1.continueVerification(EventTypes.Reciprocate, + qrDataRawBytes: + Uint8List.fromList(req2.qrCode?.qrDataRawBytes ?? [])); + + expect(req2.qrCode!.randomSharedSecret, req1.randomSharedSecretForQRCode); + expect(req1.state, KeyVerificationState.showQRSuccess); + + await client2.encryption!.keyVerificationManager.handleToDeviceEvent( + ToDeviceEvent( + type: 'm.key.verification.start', + sender: req1.client.userID!, + content: { + 'from_device': req1.client.deviceID, + 'm.relates_to': { + 'event_id': req1.transactionId, + 'rel_type': 'm.reference' + }, + 'method': EventTypes.Reciprocate, + 'secret': req1.randomSharedSecretForQRCode, + 'transaction_id': req1.transactionId, + }, + ), + ); + + expect(req2.state, KeyVerificationState.confirmQRScan); + + await req2.acceptQRScanConfirmation(); + + await client1.encryption!.keyVerificationManager.handleToDeviceEvent( + ToDeviceEvent( + type: 'm.key.verification.done', + sender: req2.client.userID!, + content: { + 'transaction_id': req2.transactionId, + }, + ), + ); + + expect(req1.state, KeyVerificationState.done); + expect(req2.state, KeyVerificationState.askSSSS); + await req2.openSSSS(recoveryKey: ssssKey); + expect(req2.state, KeyVerificationState.done); + + expect(client1.userDeviceKeys[client2.userID]?.masterKey!.verified, true); + expect(client2.userDeviceKeys[client1.userID]?.masterKey!.verified, true); + + expect( + client1.userDeviceKeys[client2.userID]?.deviceKeys[client2.deviceID] + ?.verified, + true); + expect( + client2.userDeviceKeys[client1.userID]?.deviceKeys[client1.deviceID] + ?.verified, + true); + + await client1.encryption!.keyVerificationManager.cleanup(); + await client2.encryption!.keyVerificationManager.cleanup(); + }); + + test('Run qr verification mode 1, but fail because incorrect secret', + () async { + // make sure our master key is *not* verified to not triger SSSS for now + client1.userDeviceKeys[client1.userID]!.masterKey! + .setDirectVerified(true); + client2.userDeviceKeys[client2.userID]!.masterKey! + .setDirectVerified(false); + + await client1.encryption!.ssss.clearCache(); + final req1 = + await client1.userDeviceKeys[client2.userID]!.startVerification( + newDirectChatEnableEncryption: false, + ); + + expect(req1.state, KeyVerificationState.askSSSS); + await req1.openSSSS(recoveryKey: ssssKey); + + expect(req1.state, KeyVerificationState.waitingAccept); + + final comp = Completer(); + final sub = client2.onKeyVerificationRequest.stream.listen((req) { + comp.complete(req); + }); + await client2.encryption!.keyVerificationManager.handleToDeviceEvent( + ToDeviceEvent( + type: EventTypes.KeyVerificationRequest, + sender: req1.client.userID!, + content: { + 'from_device': req1.client.deviceID, + 'methods': req1.knownVerificationMethods, + 'timestamp': DateTime.now().millisecondsSinceEpoch, + 'transaction_id': req1.transactionId + }, + ), + ); + + final req2 = await comp.future; + await sub.cancel(); + + expect( + client2.encryption!.keyVerificationManager + .getRequest(req2.transactionId!), + req2); + + expect(req1.possibleMethods, []); + await req2.acceptVerification(); + + expect(req2.possibleMethods, + [EventTypes.Sas, EventTypes.Reciprocate, EventTypes.QRShow]); + + expect(req2.state, KeyVerificationState.askChoice); + await ingestCorrectReadyEvent(req1, req2); + + expect(req1.possibleMethods, + [EventTypes.Sas, EventTypes.Reciprocate, EventTypes.QRScan]); + + expect(req1.state, KeyVerificationState.askChoice); + + expect(req1.getOurQRMode(), QRMode.verifySelfTrusted); + expect(req2.getOurQRMode(), QRMode.verifySelfUntrusted); + + // send start + await req1.continueVerification(EventTypes.Reciprocate, + qrDataRawBytes: + Uint8List.fromList(req2.qrCode?.qrDataRawBytes ?? [])); + + expect(req2.qrCode!.randomSharedSecret, req1.randomSharedSecretForQRCode); + expect(req1.state, KeyVerificationState.showQRSuccess); + + await client2.encryption!.keyVerificationManager.handleToDeviceEvent( + ToDeviceEvent( + type: 'm.key.verification.start', + sender: req1.client.userID!, + content: { + 'from_device': req1.client.deviceID, + 'm.relates_to': { + 'event_id': req1.transactionId, + 'rel_type': 'm.reference' + }, + 'method': EventTypes.Reciprocate, + 'secret': 'fake_secret', + 'transaction_id': req1.transactionId, + }, + ), + ); + + expect(req1.state, KeyVerificationState.showQRSuccess); + expect(req2.state, KeyVerificationState.error); + + await client1.encryption!.keyVerificationManager.cleanup(); + await client2.encryption!.keyVerificationManager.cleanup(); + }); + + test('Run qr verification mode 2, but both unverified master key', + () async { + // make sure our master key is *not* verified to not triger SSSS for now + await client1.userDeviceKeys[client1.userID]!.masterKey!.setBlocked(true); + await client2.userDeviceKeys[client2.userID]!.masterKey!.setBlocked(true); + // await client1.encryption!.ssss.clearCache(); + final req1 = + await client1.userDeviceKeys[client2.userID]!.startVerification( + newDirectChatEnableEncryption: false, + ); + + expect(req1.state, KeyVerificationState.waitingAccept); + + final comp = Completer(); + final sub = client2.onKeyVerificationRequest.stream.listen((req) { + comp.complete(req); + }); + await client2.encryption!.keyVerificationManager.handleToDeviceEvent( + ToDeviceEvent( + type: EventTypes.KeyVerificationRequest, + sender: req1.client.userID!, + content: { + 'from_device': req1.client.deviceID, + 'methods': req1.knownVerificationMethods, + 'timestamp': DateTime.now().millisecondsSinceEpoch, + 'transaction_id': req1.transactionId + }, + ), + ); + + final req2 = await comp.future; + await sub.cancel(); + + expect( + client2.encryption!.keyVerificationManager + .getRequest(req2.transactionId!), + req2); + + await req2.acceptVerification(); + expect(req2.possibleMethods, [EventTypes.Sas]); + expect(req2.state, KeyVerificationState.askChoice); + + await ingestCorrectReadyEvent(req1, req2); + + expect(req1.possibleMethods, [EventTypes.Sas]); + + expect(req1.state, KeyVerificationState.waitingAccept); + + expect(req1.getOurQRMode(), QRMode.verifySelfUntrusted); + expect(req2.getOurQRMode(), QRMode.verifySelfUntrusted); + + // send start + await req1.continueVerification(EventTypes.Reciprocate, + qrDataRawBytes: + Uint8List.fromList(req2.qrCode?.qrDataRawBytes ?? [])); + + expect(req1.state, KeyVerificationState.error); + + await client2.encryption!.keyVerificationManager.handleToDeviceEvent( + ToDeviceEvent( + type: 'm.key.verification.start', + sender: req1.client.userID!, + content: { + 'from_device': req1.client.deviceID, + 'm.relates_to': { + 'event_id': req1.transactionId, + 'rel_type': 'm.reference' + }, + 'method': EventTypes.Reciprocate, + 'secret': 'stub_incorrect_secret_here', + 'transaction_id': req1.transactionId, + }, + ), + ); + + expect(req1.state, KeyVerificationState.error); + expect(req2.state, KeyVerificationState.error); + + await client1.encryption!.keyVerificationManager.cleanup(); + await client2.encryption!.keyVerificationManager.cleanup(); + }); + }, skip: skip); +} diff --git a/test/fake_client.dart b/test/fake_client.dart index acfb5e04..15f83d14 100644 --- a/test/fake_client.dart +++ b/test/fake_client.dart @@ -47,3 +47,23 @@ Future getClient() async { await Future.delayed(Duration(milliseconds: 10)); return client; } + +Future getOtherClient() async { + final client = Client( + 'othertestclient', + httpClient: FakeMatrixApi(), + databaseBuilder: getDatabase, + ); + FakeMatrixApi.client = client; + await client.checkHomeserver(Uri.parse('https://fakeServer.notExisting'), + checkWellKnown: false); + await client.init( + newToken: '1234', + newUserID: '@test:fakeServer.notExisting', + newHomeserver: client.homeserver, + newDeviceName: 'Text Matrix Client', + newDeviceID: 'OTHERDEVICE', + ); + await Future.delayed(Duration(milliseconds: 10)); + return client; +} diff --git a/test/fake_matrix_api.dart b/test/fake_matrix_api.dart index af2a7f75..852bb2b9 100644 --- a/test/fake_matrix_api.dart +++ b/test/fake_matrix_api.dart @@ -2299,7 +2299,8 @@ class FakeMatrixApi extends BaseClient { 'user_id': '@othertest:fakeServer.notExisting', 'usage': ['master'], 'keys': { - 'ed25519:master': 'master', + 'ed25519:92mAXjsmbTbrE6zyShpR869jnrANO75H8nYY0nDLoJ8': + '92mAXjsmbTbrE6zyShpR869jnrANO75H8nYY0nDLoJ8', }, 'signatures': {}, },