1844 lines
57 KiB
Dart
1844 lines
57 KiB
Dart
/*
|
|
* Famedly Matrix SDK
|
|
* Copyright (C) 2020, 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 <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:canonical_json/canonical_json.dart';
|
|
import 'package:typed_data/typed_data.dart';
|
|
import 'package:vodozemac/vodozemac.dart' as vod;
|
|
|
|
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;
|
|
|
|
/*
|
|
+-------------+ +-----------+
|
|
| AliceDevice | | BobDevice |
|
|
+-------------+ +-----------+
|
|
| |
|
|
| (m.key.verification.request) |
|
|
|-------------------------------->| (ASK FOR VERIFICATION REQUEST)
|
|
| |
|
|
| (m.key.verification.ready) |
|
|
|<--------------------------------|
|
|
| |
|
|
| (m.key.verification.start) | we will probably not send this
|
|
|<--------------------------------| for simplicities sake
|
|
| |
|
|
| m.key.verification.start |
|
|
|-------------------------------->| (ASK FOR VERIFICATION REQUEST)
|
|
| |
|
|
| m.key.verification.accept |
|
|
|<--------------------------------|
|
|
| |
|
|
| m.key.verification.key |
|
|
|-------------------------------->|
|
|
| |
|
|
| m.key.verification.key |
|
|
|<--------------------------------|
|
|
| |
|
|
| COMPARE EMOJI / NUMBERS |
|
|
| |
|
|
| m.key.verification.mac |
|
|
|-------------------------------->| success
|
|
| |
|
|
| m.key.verification.mac |
|
|
success |<--------------------------------|
|
|
| |
|
|
*/
|
|
|
|
/// 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, 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<String> _intersect(List<String>? a, List<dynamic>? b) =>
|
|
(b == null || a == null) ? [] : a.where(b.contains).toList();
|
|
|
|
List<String> _calculatePossibleMethods(
|
|
List<String> knownMethods,
|
|
List<dynamic> payloadMethods,
|
|
) {
|
|
final output = <String>[];
|
|
final copyKnownMethods = List<String>.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<int> _bytesToInt(Uint8List bytes, int totalBits) {
|
|
final ret = <int>[];
|
|
var current = 0;
|
|
var numBits = 0;
|
|
for (final byte in bytes) {
|
|
for (final bit in [7, 6, 5, 4, 3, 2, 1, 0]) {
|
|
numBits++;
|
|
current |= ((byte >> bit) & 1) << (totalBits - numBits);
|
|
if (numBits >= totalBits) {
|
|
ret.add(current);
|
|
current = 0;
|
|
numBits = 0;
|
|
}
|
|
}
|
|
}
|
|
return ret;
|
|
}
|
|
|
|
_KeyVerificationMethod _makeVerificationMethod(
|
|
String type,
|
|
KeyVerification request,
|
|
) {
|
|
if (type == EventTypes.Sas) {
|
|
return _KeyVerificationMethodSas(request: request);
|
|
}
|
|
if (type == EventTypes.Reciprocate) {
|
|
return _KeyVerificationMethodQRReciprocate(request: request);
|
|
}
|
|
throw Exception('Unkown method type');
|
|
}
|
|
|
|
class KeyVerification {
|
|
String? transactionId;
|
|
final Encryption encryption;
|
|
Client get client => encryption.client;
|
|
final Room? room;
|
|
final String userId;
|
|
void Function()? onUpdate;
|
|
String? get deviceId => _deviceId;
|
|
String? _deviceId;
|
|
bool startedVerification = false;
|
|
_KeyVerificationMethod? _method;
|
|
|
|
List<String> possibleMethods = [];
|
|
List<String> oppositePossibleMethods = [];
|
|
|
|
Map<String, dynamic>? startPayload;
|
|
String? _nextAction;
|
|
List<SignableKey> _verifiedDevices = [];
|
|
|
|
DateTime lastActivity;
|
|
String? lastStep;
|
|
|
|
KeyVerificationState state = KeyVerificationState.waitingAccept;
|
|
bool canceled = false;
|
|
String? canceledCode;
|
|
String? canceledReason;
|
|
bool get isDone =>
|
|
canceled ||
|
|
{KeyVerificationState.error, KeyVerificationState.done}.contains(state);
|
|
|
|
// qr stuff
|
|
QRCode? qrCode;
|
|
String? randomSharedSecretForQRCode;
|
|
SignableKey? keyToVerify;
|
|
KeyVerification({
|
|
required this.encryption,
|
|
this.room,
|
|
required this.userId,
|
|
String? deviceId,
|
|
this.onUpdate,
|
|
}) : _deviceId = deviceId,
|
|
lastActivity = DateTime.now();
|
|
|
|
void dispose() {
|
|
Logs().i('[Key Verification] disposing object...');
|
|
randomSharedSecretForQRCode = null;
|
|
_method?.dispose();
|
|
}
|
|
|
|
static String? getTransactionId(Map<String, dynamic> payload) {
|
|
return payload['transaction_id'] ??
|
|
(payload['m.relates_to'] is Map
|
|
? payload['m.relates_to']['event_id']
|
|
: null);
|
|
}
|
|
|
|
List<String> get knownVerificationMethods {
|
|
final methods = <String>{};
|
|
if (client.verificationMethods.contains(KeyVerificationMethod.numbers) ||
|
|
client.verificationMethods.contains(KeyVerificationMethod.emoji)) {
|
|
methods.add(EventTypes.Sas);
|
|
}
|
|
|
|
/// `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));
|
|
|
|
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();
|
|
}
|
|
|
|
/// 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<void> continueVerification(
|
|
String type, {
|
|
Uint8List? qrDataRawBytes,
|
|
}) async {
|
|
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<void> sendRequest() async {
|
|
await send(
|
|
EventTypes.KeyVerificationRequest,
|
|
{
|
|
'methods': knownVerificationMethods,
|
|
if (room == null) 'timestamp': DateTime.now().millisecondsSinceEpoch,
|
|
},
|
|
);
|
|
startedVerification = true;
|
|
setState(KeyVerificationState.waitingAccept);
|
|
lastActivity = DateTime.now();
|
|
}
|
|
|
|
Future<void> start() async {
|
|
if (room == null) {
|
|
transactionId = client.generateUniqueTransactionId();
|
|
}
|
|
if (encryption.crossSigning.enabled &&
|
|
!(await encryption.crossSigning.isCached()) &&
|
|
!client.isUnknownSession) {
|
|
setState(KeyVerificationState.askSSSS);
|
|
_nextAction = 'request';
|
|
} else {
|
|
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<void> handlePayload(
|
|
String type,
|
|
Map<String, dynamic> payload, [
|
|
String? eventId,
|
|
]) async {
|
|
if (isDone) {
|
|
return; // no need to do anything with already canceled requests
|
|
}
|
|
while (_handlePayloadLock) {
|
|
await Future.delayed(Duration(milliseconds: 50));
|
|
}
|
|
_handlePayloadLock = true;
|
|
Logs().i('[Key Verification] Received type $type: $payload');
|
|
try {
|
|
var thisLastStep = lastStep;
|
|
switch (type) {
|
|
case EventTypes.KeyVerificationRequest:
|
|
_deviceId ??= payload['from_device'];
|
|
transactionId ??= eventId ?? payload['transaction_id'];
|
|
// verify the timestamp
|
|
final now = DateTime.now();
|
|
final verifyTime =
|
|
DateTime.fromMillisecondsSinceEpoch(payload['timestamp']);
|
|
if (now.subtract(Duration(minutes: 10)).isAfter(verifyTime) ||
|
|
now.add(Duration(minutes: 5)).isBefore(verifyTime)) {
|
|
// if the request is more than 20min in the past we just silently fail it
|
|
// to not generate too many cancels
|
|
await cancel(
|
|
'm.timeout',
|
|
now.subtract(Duration(minutes: 20)).isAfter(verifyTime),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// ensure we have the other sides keys
|
|
if (client.userDeviceKeys[userId]?.deviceKeys[deviceId!] == null) {
|
|
await client.updateUserDeviceKeys(additionalUsers: {userId});
|
|
if (client.userDeviceKeys[userId]?.deviceKeys[deviceId!] == null) {
|
|
await cancel('im.fluffychat.unknown_device');
|
|
return;
|
|
}
|
|
}
|
|
|
|
oppositePossibleMethods = List<String>.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 EventTypes.KeyVerificationReady:
|
|
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<DeviceKeys>.from(
|
|
client.userDeviceKeys[userId]?.deviceKeys.values ??
|
|
Iterable.empty(),
|
|
);
|
|
devices.removeWhere(
|
|
(d) => {deviceId, client.deviceID}.contains(d.deviceId),
|
|
);
|
|
final cancelPayload = <String, dynamic>{
|
|
'reason': 'Another device accepted the request',
|
|
'code': 'm.accepted',
|
|
};
|
|
makePayload(cancelPayload);
|
|
await client.sendToDeviceEncrypted(
|
|
devices,
|
|
EventTypes.KeyVerificationCancel,
|
|
cancelPayload,
|
|
);
|
|
}
|
|
_deviceId ??= payload['from_device'];
|
|
|
|
// ensure we have the other sides keys
|
|
if (client.userDeviceKeys[userId]?.deviceKeys[deviceId!] == null) {
|
|
await client.updateUserDeviceKeys(additionalUsers: {userId});
|
|
if (client.userDeviceKeys[userId]?.deviceKeys[deviceId!] == null) {
|
|
await cancel('im.fluffychat.unknown_device');
|
|
return;
|
|
}
|
|
}
|
|
|
|
oppositePossibleMethods = List<String>.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;
|
|
|
|
// 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'];
|
|
transactionId ??= eventId ?? payload['transaction_id'];
|
|
if (_method != null) {
|
|
// the other side sent us a start, even though we already sent one
|
|
if (payload['method'] == _method!.type) {
|
|
// same method. Determine priority
|
|
final ourEntry = '${client.userID}|${client.deviceID}';
|
|
final entries = [ourEntry, '$userId|$deviceId'];
|
|
entries.sort();
|
|
if (entries.first == ourEntry) {
|
|
// our start won, nothing to do
|
|
return;
|
|
} else {
|
|
// the other start won, let's hand off
|
|
startedVerification = false; // it is now as if they started
|
|
thisLastStep = lastStep =
|
|
EventTypes.KeyVerificationRequest; // we fake the last step
|
|
_method!.dispose(); // in case anything got created already
|
|
}
|
|
} else {
|
|
// methods don't match up, let's cancel this
|
|
await cancel('m.unexpected_message');
|
|
return;
|
|
}
|
|
}
|
|
if (!(await verifyLastStep([
|
|
EventTypes.KeyVerificationRequest,
|
|
EventTypes.KeyVerificationReady,
|
|
]))) {
|
|
return; // abort
|
|
}
|
|
if (!knownVerificationMethods.contains(payload['method'])) {
|
|
await cancel('m.unknown_method');
|
|
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});
|
|
if (client.userDeviceKeys[userId]?.deviceKeys[deviceId!] == null) {
|
|
await cancel('im.fluffychat.unknown_device');
|
|
return;
|
|
}
|
|
}
|
|
|
|
_method = _makeVerificationMethod(payload['method'], this);
|
|
if (lastStep == null) {
|
|
// validate the start time
|
|
if (room != null) {
|
|
// we just silently ignore in-room-verification starts
|
|
await cancel('m.unknown_method', true);
|
|
return;
|
|
}
|
|
// validate the specific payload
|
|
if (!_method!.validateStart(payload)) {
|
|
await cancel('m.unknown_method');
|
|
return;
|
|
}
|
|
startPayload = payload;
|
|
setState(KeyVerificationState.askAccept);
|
|
} else {
|
|
Logs().i('handling start in method.....');
|
|
await _method!.handlePayload(type, payload);
|
|
}
|
|
break;
|
|
case EventTypes.KeyVerificationDone:
|
|
if (state == KeyVerificationState.showQRSuccess) {
|
|
await send(EventTypes.KeyVerificationDone, {});
|
|
setState(KeyVerificationState.done);
|
|
}
|
|
break;
|
|
case EventTypes.KeyVerificationCancel:
|
|
canceled = true;
|
|
canceledCode = payload['code'];
|
|
canceledReason = payload['reason'];
|
|
setState(KeyVerificationState.error);
|
|
break;
|
|
default:
|
|
final method = _method;
|
|
if (method != null) {
|
|
await method.handlePayload(type, payload);
|
|
} else {
|
|
await cancel('m.invalid_message');
|
|
}
|
|
break;
|
|
}
|
|
if (lastStep == thisLastStep) {
|
|
lastStep = type;
|
|
}
|
|
} catch (err, stacktrace) {
|
|
Logs().e('[Key Verification] An error occured', err, stacktrace);
|
|
await cancel('m.invalid_message');
|
|
} finally {
|
|
_handlePayloadLock = false;
|
|
}
|
|
}
|
|
|
|
void otherDeviceAccepted() {
|
|
canceled = true;
|
|
canceledCode = 'm.accepted';
|
|
canceledReason = 'm.accepted';
|
|
setState(KeyVerificationState.error);
|
|
}
|
|
|
|
Future<void> openSSSS({
|
|
String? passphrase,
|
|
String? recoveryKey,
|
|
String? keyOrPassphrase,
|
|
bool skip = false,
|
|
}) async {
|
|
Future<void> next() async {
|
|
if (_nextAction == 'request') {
|
|
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);
|
|
}
|
|
}
|
|
|
|
if (skip) {
|
|
await next();
|
|
return;
|
|
}
|
|
final handle = encryption.ssss.open(EventTypes.CrossSigningUserSigning);
|
|
await handle.unlock(
|
|
passphrase: passphrase,
|
|
recoveryKey: recoveryKey,
|
|
keyOrPassphrase: keyOrPassphrase,
|
|
);
|
|
await handle.maybeCacheAll();
|
|
await next();
|
|
}
|
|
|
|
/// called when the user accepts an incoming verification
|
|
Future<void> acceptVerification() async {
|
|
if (!(await verifyLastStep([
|
|
EventTypes.KeyVerificationRequest,
|
|
EventTypes.KeyVerificationStart,
|
|
]))) {
|
|
return;
|
|
}
|
|
setState(KeyVerificationState.waitingAccept);
|
|
if (lastStep == EventTypes.KeyVerificationRequest) {
|
|
final copyKnownVerificationMethods =
|
|
List<String>.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<String>.from(possibleMethods);
|
|
possibleMethods = _calculatePossibleMethods(
|
|
copyKnownVerificationMethods,
|
|
copyPossibleMethods,
|
|
);
|
|
}
|
|
}
|
|
// we need to send a ready event
|
|
await send(EventTypes.KeyVerificationReady, {
|
|
'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!
|
|
.handlePayload(EventTypes.KeyVerificationStart, startPayload!);
|
|
}
|
|
}
|
|
|
|
/// called when the user rejects an incoming verification
|
|
Future<void> rejectVerification() async {
|
|
if (isDone) {
|
|
return;
|
|
}
|
|
if (!(await verifyLastStep([
|
|
EventTypes.KeyVerificationRequest,
|
|
EventTypes.KeyVerificationStart,
|
|
]))) {
|
|
return;
|
|
}
|
|
await cancel('m.user');
|
|
}
|
|
|
|
/// call this to confirm that your other device has shown a shield and is in
|
|
/// `done` state.
|
|
Future<void> acceptQRScanConfirmation() async {
|
|
if (_method is _KeyVerificationMethodQRReciprocate &&
|
|
state == KeyVerificationState.confirmQRScan) {
|
|
await (_method as _KeyVerificationMethodQRReciprocate)
|
|
.acceptQRScanConfirmation();
|
|
}
|
|
}
|
|
|
|
Future<void> acceptSas() async {
|
|
if (_method is _KeyVerificationMethodSas) {
|
|
await (_method as _KeyVerificationMethodSas).acceptSas();
|
|
}
|
|
}
|
|
|
|
Future<void> rejectSas() async {
|
|
if (_method is _KeyVerificationMethodSas) {
|
|
await (_method as _KeyVerificationMethodSas).rejectSas();
|
|
}
|
|
}
|
|
|
|
List<int> get sasNumbers {
|
|
if (_method is _KeyVerificationMethodSas) {
|
|
return _bytesToInt((_method as _KeyVerificationMethodSas).makeSas(5), 13)
|
|
.map((n) => n + 1000)
|
|
.toList();
|
|
}
|
|
return [];
|
|
}
|
|
|
|
List<String> get sasTypes {
|
|
if (_method is _KeyVerificationMethodSas) {
|
|
return (_method as _KeyVerificationMethodSas).authenticationTypes ?? [];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
List<KeyVerificationEmoji> get sasEmojis {
|
|
if (_method is _KeyVerificationMethodSas) {
|
|
final numbers =
|
|
_bytesToInt((_method as _KeyVerificationMethodSas).makeSas(6), 6);
|
|
return numbers.map((n) => KeyVerificationEmoji(n)).toList().sublist(0, 7);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
Future<void> maybeRequestSSSSSecrets([int i = 0]) async {
|
|
final requestInterval = <int>[10, 60];
|
|
if ((!encryption.crossSigning.enabled ||
|
|
(encryption.crossSigning.enabled &&
|
|
(await encryption.crossSigning.isCached()))) &&
|
|
(!encryption.keyManager.enabled ||
|
|
(encryption.keyManager.enabled &&
|
|
(await encryption.keyManager.isCached())))) {
|
|
// no need to request cache, we already have it
|
|
return;
|
|
}
|
|
unawaited(
|
|
encryption.ssss
|
|
.maybeRequestAll(_verifiedDevices.whereType<DeviceKeys>().toList()),
|
|
);
|
|
if (requestInterval.length <= i) {
|
|
return;
|
|
}
|
|
Timer(
|
|
Duration(seconds: requestInterval[i]),
|
|
() => maybeRequestSSSSSecrets(i + 1),
|
|
);
|
|
}
|
|
|
|
Future<void> verifyKeysSAS(
|
|
Map<String, String> keys,
|
|
Future<bool> Function(String, SignableKey) verifier,
|
|
) async {
|
|
_verifiedDevices = <SignableKey>[];
|
|
|
|
final userDeviceKey = client.userDeviceKeys[userId];
|
|
if (userDeviceKey == null) {
|
|
await cancel('m.key_mismatch');
|
|
return;
|
|
}
|
|
for (final entry in keys.entries) {
|
|
final keyId = entry.key;
|
|
final verifyDeviceId = keyId.substring('ed25519:'.length);
|
|
final keyInfo = entry.value;
|
|
final key = userDeviceKey.getKey(verifyDeviceId);
|
|
if (key != null) {
|
|
if (!(await verifier(keyInfo, key))) {
|
|
await cancel('m.key_mismatch');
|
|
return;
|
|
}
|
|
_verifiedDevices.add(key);
|
|
}
|
|
}
|
|
// okay, we reached this far, so all the devices are verified!
|
|
var verifiedMasterKey = false;
|
|
final wasUnknownSession = client.isUnknownSession;
|
|
for (final key in _verifiedDevices) {
|
|
await key.setVerified(
|
|
true,
|
|
false,
|
|
); // we don't want to sign the keys juuuust yet
|
|
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();
|
|
}
|
|
await send(EventTypes.KeyVerificationDone, {});
|
|
|
|
var askingSSSS = false;
|
|
if (encryption.crossSigning.enabled &&
|
|
encryption.crossSigning.signable(_verifiedDevices)) {
|
|
// 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(_verifiedDevices);
|
|
} else if (!wasUnknownSession) {
|
|
askingSSSS = true;
|
|
}
|
|
}
|
|
if (askingSSSS) {
|
|
setState(KeyVerificationState.askSSSS);
|
|
_nextAction = 'done';
|
|
} else {
|
|
setState(KeyVerificationState.done);
|
|
}
|
|
}
|
|
|
|
/// shower is true only for reciprocated verifications (shower side)
|
|
Future<void> 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<SignableKey>.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<bool> verifyActivity() async {
|
|
if (lastActivity.add(Duration(minutes: 10)).isAfter(DateTime.now())) {
|
|
lastActivity = DateTime.now();
|
|
return true;
|
|
}
|
|
await cancel('m.timeout');
|
|
return false;
|
|
}
|
|
|
|
Future<bool> verifyLastStep(List<String?> checkLastStep) async {
|
|
if (!(await verifyActivity())) {
|
|
return false;
|
|
}
|
|
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;
|
|
}
|
|
|
|
Future<void> cancel([String code = 'm.unknown', bool quiet = false]) async {
|
|
if (!quiet && (deviceId != null || room != null)) {
|
|
await send(EventTypes.KeyVerificationCancel, {
|
|
'reason': code,
|
|
'code': code,
|
|
});
|
|
}
|
|
canceled = true;
|
|
canceledCode = code;
|
|
setState(KeyVerificationState.error);
|
|
}
|
|
|
|
void makePayload(Map<String, dynamic> payload) {
|
|
payload['from_device'] = client.deviceID;
|
|
if (transactionId != null) {
|
|
if (room != null) {
|
|
payload['m.relates_to'] = {
|
|
'rel_type': 'm.reference',
|
|
'event_id': transactionId,
|
|
};
|
|
} else {
|
|
payload['transaction_id'] = transactionId;
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> send(
|
|
String type,
|
|
Map<String, dynamic> payload,
|
|
) async {
|
|
makePayload(payload);
|
|
Logs().i('[Key Verification] Sending type $type: $payload');
|
|
if (room != null) {
|
|
Logs().i('[Key Verification] Sending to $userId in room ${room!.id}...');
|
|
if ({EventTypes.KeyVerificationRequest}.contains(type)) {
|
|
payload['msgtype'] = type;
|
|
payload['to'] = userId;
|
|
payload['body'] =
|
|
'Attempting verification request. ($type) Apparently your client doesn\'t support this';
|
|
type = EventTypes.Message;
|
|
}
|
|
final newTransactionId = await room!.sendEvent(payload, type: type);
|
|
if (transactionId == null) {
|
|
transactionId = newTransactionId;
|
|
encryption.keyVerificationManager.addRequest(this);
|
|
}
|
|
} else {
|
|
Logs().i('[Key Verification] Sending to $userId device $deviceId...');
|
|
if (deviceId == '*') {
|
|
if ({
|
|
EventTypes.KeyVerificationRequest,
|
|
EventTypes.KeyVerificationCancel,
|
|
}.contains(type)) {
|
|
final deviceKeys =
|
|
client.userDeviceKeys[userId]?.deviceKeys.values.where(
|
|
(deviceKey) => deviceKey.hasValidSignatureChain(
|
|
verifiedByTheirMasterKey: true,
|
|
),
|
|
);
|
|
|
|
if (deviceKeys != null) {
|
|
await client.sendToDeviceEncrypted(
|
|
deviceKeys.toList(),
|
|
type,
|
|
payload,
|
|
);
|
|
}
|
|
} else {
|
|
Logs().e(
|
|
'[Key Verification] Tried to broadcast and un-broadcastable type: $type',
|
|
);
|
|
}
|
|
} else {
|
|
if (client.userDeviceKeys[userId]?.deviceKeys[deviceId] != null) {
|
|
await client.sendToDeviceEncrypted(
|
|
[client.userDeviceKeys[userId]!.deviceKeys[deviceId]!],
|
|
type,
|
|
payload,
|
|
);
|
|
} else {
|
|
Logs().e('[Key Verification] Unknown device');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void setState(KeyVerificationState newState) {
|
|
if (state != KeyVerificationState.error) {
|
|
state = newState;
|
|
}
|
|
|
|
onUpdate?.call();
|
|
}
|
|
|
|
static const String prefix = 'MATRIX';
|
|
static const int version = 0x02;
|
|
|
|
Future<bool> 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<QRCode?> 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 {
|
|
KeyVerification request;
|
|
Encryption get encryption => request.encryption;
|
|
Client get client => request.client;
|
|
_KeyVerificationMethod({required this.request});
|
|
|
|
Future<void> handlePayload(String type, Map<String, dynamic> payload);
|
|
bool validateStart(Map<String, dynamic> payload) {
|
|
return false;
|
|
}
|
|
|
|
late String _type;
|
|
String get type => _type;
|
|
|
|
Future<void> sendStart();
|
|
void dispose() {}
|
|
}
|
|
|
|
class _KeyVerificationMethodQRReciprocate extends _KeyVerificationMethod {
|
|
_KeyVerificationMethodQRReciprocate({required super.request});
|
|
|
|
@override
|
|
// ignore: overridden_fields
|
|
final _type = EventTypes.Reciprocate;
|
|
|
|
@override
|
|
bool validateStart(Map<String, dynamic> payload) {
|
|
if (payload['method'] != type) return false;
|
|
if (payload['secret'] != request.randomSharedSecretForQRCode) return false;
|
|
return true;
|
|
}
|
|
|
|
@override
|
|
Future<void> handlePayload(String type, Map<String, dynamic> payload) async {
|
|
try {
|
|
switch (type) {
|
|
case EventTypes.KeyVerificationStart:
|
|
if (!(await request.verifyLastStep([
|
|
EventTypes.KeyVerificationReady,
|
|
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<void> 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<void> sendStart() async {
|
|
final payload = <String, dynamic>{
|
|
'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.v2',
|
|
'hkdf-hmac-sha256',
|
|
];
|
|
|
|
class _KeyVerificationMethodSas extends _KeyVerificationMethod {
|
|
_KeyVerificationMethodSas({required super.request});
|
|
|
|
@override
|
|
// ignore: overridden_fields
|
|
final _type = EventTypes.Sas;
|
|
|
|
String? keyAgreementProtocol;
|
|
String? hash;
|
|
String? messageAuthenticationCode;
|
|
List<String>? authenticationTypes;
|
|
late String startCanonicalJson;
|
|
String? commitment;
|
|
late String theirPublicKey;
|
|
Map<String, dynamic>? macPayload;
|
|
vod.Sas? sas;
|
|
vod.EstablishedSas? establishedSas;
|
|
|
|
List<String> get knownAuthentificationTypes {
|
|
final types = <String>[];
|
|
if (request.client.verificationMethods
|
|
.contains(KeyVerificationMethod.emoji)) {
|
|
types.add('emoji');
|
|
}
|
|
if (request.client.verificationMethods
|
|
.contains(KeyVerificationMethod.numbers)) {
|
|
types.add('decimal');
|
|
}
|
|
return types;
|
|
}
|
|
|
|
@override
|
|
Future<void> handlePayload(String type, Map<String, dynamic> payload) async {
|
|
try {
|
|
switch (type) {
|
|
case EventTypes.KeyVerificationStart:
|
|
if (!(await request.verifyLastStep([
|
|
EventTypes.KeyVerificationReady,
|
|
EventTypes.KeyVerificationRequest,
|
|
EventTypes.KeyVerificationStart,
|
|
]))) {
|
|
return; // abort
|
|
}
|
|
if (!validateStart(payload)) {
|
|
await request.cancel('m.unknown_method');
|
|
return;
|
|
}
|
|
await _sendAccept();
|
|
break;
|
|
case EventTypes.KeyVerificationAccept:
|
|
if (!(await request.verifyLastStep([
|
|
EventTypes.KeyVerificationReady,
|
|
EventTypes.KeyVerificationRequest,
|
|
]))) {
|
|
return;
|
|
}
|
|
if (!_handleAccept(payload)) {
|
|
await request.cancel('m.unknown_method');
|
|
return;
|
|
}
|
|
await _sendKey();
|
|
break;
|
|
case 'm.key.verification.key':
|
|
if (!(await request.verifyLastStep([
|
|
EventTypes.KeyVerificationAccept,
|
|
EventTypes.KeyVerificationStart,
|
|
]))) {
|
|
return;
|
|
}
|
|
_handleKey(payload);
|
|
if (request.lastStep == EventTypes.KeyVerificationStart) {
|
|
// we need to send our key
|
|
await _sendKey();
|
|
} else {
|
|
// we already sent our key, time to verify the commitment being valid
|
|
if (await _validateCommitment() == false) {
|
|
await request.cancel('m.mismatched_commitment');
|
|
return;
|
|
}
|
|
}
|
|
request.setState(KeyVerificationState.askSas);
|
|
break;
|
|
case 'm.key.verification.mac':
|
|
if (!(await request.verifyLastStep(['m.key.verification.key']))) {
|
|
return;
|
|
}
|
|
macPayload = payload;
|
|
if (request.state == KeyVerificationState.waitingSas) {
|
|
await _processMac();
|
|
}
|
|
break;
|
|
}
|
|
} catch (err, stacktrace) {
|
|
Logs().e('[Key Verification SAS] An error occured', err, stacktrace);
|
|
if (request.deviceId != null) {
|
|
await request.cancel('m.invalid_message');
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> acceptSas() async {
|
|
await _sendMac();
|
|
request.setState(KeyVerificationState.waitingSas);
|
|
if (macPayload != null) {
|
|
await _processMac();
|
|
}
|
|
}
|
|
|
|
Future<void> rejectSas() async {
|
|
await request.cancel('m.mismatched_sas');
|
|
}
|
|
|
|
@override
|
|
Future<void> sendStart() async {
|
|
final payload = <String, dynamic>{
|
|
'method': type,
|
|
'key_agreement_protocols': knownKeyAgreementProtocols,
|
|
'hashes': knownHashes,
|
|
'message_authentication_codes': knownHashesAuthentificationCodes,
|
|
'short_authentication_string': knownAuthentificationTypes,
|
|
};
|
|
request.makePayload(payload);
|
|
// We just store the canonical json in here for later verification
|
|
startCanonicalJson = String.fromCharCodes(canonicalJson.encode(payload));
|
|
await request.send(EventTypes.KeyVerificationStart, payload);
|
|
}
|
|
|
|
@override
|
|
bool validateStart(Map<String, dynamic> payload) {
|
|
if (payload['method'] != type) {
|
|
return false;
|
|
}
|
|
final possibleKeyAgreementProtocols = _intersect(
|
|
knownKeyAgreementProtocols,
|
|
payload['key_agreement_protocols'],
|
|
);
|
|
if (possibleKeyAgreementProtocols.isEmpty) {
|
|
return false;
|
|
}
|
|
keyAgreementProtocol = possibleKeyAgreementProtocols.first;
|
|
final possibleHashes = _intersect(knownHashes, payload['hashes']);
|
|
if (possibleHashes.isEmpty) {
|
|
return false;
|
|
}
|
|
hash = possibleHashes.first;
|
|
final possibleMessageAuthenticationCodes = _intersect(
|
|
knownHashesAuthentificationCodes,
|
|
payload['message_authentication_codes'],
|
|
);
|
|
if (possibleMessageAuthenticationCodes.isEmpty) {
|
|
return false;
|
|
}
|
|
|
|
// intersect should make sure we choose v2 over the dep'd one
|
|
messageAuthenticationCode = possibleMessageAuthenticationCodes.first;
|
|
final possibleAuthenticationTypes = _intersect(
|
|
knownAuthentificationTypes,
|
|
payload['short_authentication_string'],
|
|
);
|
|
if (possibleAuthenticationTypes.isEmpty) {
|
|
return false;
|
|
}
|
|
authenticationTypes = possibleAuthenticationTypes;
|
|
startCanonicalJson = String.fromCharCodes(canonicalJson.encode(payload));
|
|
return true;
|
|
}
|
|
|
|
Future<void> _sendAccept() async {
|
|
final sas = this.sas = vod.Sas();
|
|
commitment = await _makeCommitment(sas.publicKey, startCanonicalJson);
|
|
await request.send(EventTypes.KeyVerificationAccept, {
|
|
'method': type,
|
|
'key_agreement_protocol': keyAgreementProtocol,
|
|
'hash': hash,
|
|
'message_authentication_code': messageAuthenticationCode,
|
|
'short_authentication_string': authenticationTypes,
|
|
'commitment': commitment,
|
|
});
|
|
}
|
|
|
|
bool _handleAccept(Map<String, dynamic> payload) {
|
|
if (!knownKeyAgreementProtocols
|
|
.contains(payload['key_agreement_protocol'])) {
|
|
return false;
|
|
}
|
|
keyAgreementProtocol = payload['key_agreement_protocol'];
|
|
if (!knownHashes.contains(payload['hash'])) {
|
|
return false;
|
|
}
|
|
hash = payload['hash'];
|
|
if (!knownHashesAuthentificationCodes
|
|
.contains(payload['message_authentication_code'])) {
|
|
return false;
|
|
}
|
|
messageAuthenticationCode = payload['message_authentication_code'];
|
|
final possibleAuthenticationTypes = _intersect(
|
|
knownAuthentificationTypes,
|
|
payload['short_authentication_string'],
|
|
);
|
|
if (possibleAuthenticationTypes.isEmpty) {
|
|
return false;
|
|
}
|
|
authenticationTypes = possibleAuthenticationTypes;
|
|
commitment = payload['commitment'];
|
|
sas = vod.Sas();
|
|
return true;
|
|
}
|
|
|
|
Future<void> _sendKey() async {
|
|
await request.send('m.key.verification.key', {
|
|
'key': sas!.publicKey,
|
|
});
|
|
}
|
|
|
|
void _handleKey(Map<String, dynamic> payload) {
|
|
theirPublicKey = payload['key'];
|
|
final sas = this.sas;
|
|
if (sas == null || sas.disposed) {
|
|
throw Exception('SAS object is disposed');
|
|
}
|
|
establishedSas = sas.establishSasSecret(payload['key']);
|
|
}
|
|
|
|
Future<bool> _validateCommitment() async {
|
|
final checkCommitment =
|
|
await _makeCommitment(theirPublicKey, startCanonicalJson);
|
|
return commitment == checkCommitment;
|
|
}
|
|
|
|
Uint8List makeSas(int bytes) {
|
|
var sasInfo = '';
|
|
if (keyAgreementProtocol == 'curve25519-hkdf-sha256') {
|
|
final ourInfo = '${client.userID}|${client.deviceID}|${sas!.publicKey}|';
|
|
final theirInfo =
|
|
'${request.userId}|${request.deviceId}|$theirPublicKey|';
|
|
sasInfo =
|
|
'MATRIX_KEY_VERIFICATION_SAS|${request.startedVerification ? ourInfo + theirInfo : theirInfo + ourInfo}${request.transactionId!}';
|
|
} else if (keyAgreementProtocol == 'curve25519') {
|
|
final ourInfo = client.userID! + client.deviceID!;
|
|
final theirInfo = request.userId + request.deviceId!;
|
|
sasInfo =
|
|
'MATRIX_KEY_VERIFICATION_SAS${request.startedVerification ? ourInfo + theirInfo : theirInfo + ourInfo}${request.transactionId!}';
|
|
} else {
|
|
throw Exception('Unknown key agreement protocol');
|
|
}
|
|
return establishedSas!.generateBytes(sasInfo, bytes);
|
|
}
|
|
|
|
Future<void> _sendMac() async {
|
|
final baseInfo =
|
|
'MATRIX_KEY_VERIFICATION_MAC${client.userID!}${client.deviceID!}${request.userId}${request.deviceId!}${request.transactionId!}';
|
|
final mac = <String, String>{};
|
|
final keyList = <String>[];
|
|
|
|
// now add all the keys we want the other to verify
|
|
// for now it is just our device key, once we have cross-signing
|
|
// we would also add the cross signing key here
|
|
final deviceKeyId = 'ed25519:${client.deviceID}';
|
|
mac[deviceKeyId] =
|
|
_calculateMac(encryption.fingerprintKey!, baseInfo + deviceKeyId);
|
|
keyList.add(deviceKeyId);
|
|
|
|
final masterKey = client.userDeviceKeys[client.userID]?.masterKey;
|
|
if (masterKey != null && masterKey.verified) {
|
|
// we have our own master key verified, let's send it!
|
|
final masterKeyId = 'ed25519:${masterKey.publicKey}';
|
|
mac[masterKeyId] =
|
|
_calculateMac(masterKey.publicKey!, baseInfo + masterKeyId);
|
|
keyList.add(masterKeyId);
|
|
}
|
|
|
|
keyList.sort();
|
|
final keys = _calculateMac(keyList.join(','), '${baseInfo}KEY_IDS');
|
|
await request.send('m.key.verification.mac', {
|
|
'mac': mac,
|
|
'keys': keys,
|
|
});
|
|
}
|
|
|
|
Future<void> _processMac() async {
|
|
final payload = macPayload!;
|
|
final baseInfo =
|
|
'MATRIX_KEY_VERIFICATION_MAC${request.userId}${request.deviceId!}${client.userID!}${client.deviceID!}${request.transactionId!}';
|
|
|
|
final keyList = payload['mac'].keys.toList();
|
|
keyList.sort();
|
|
if (payload['keys'] !=
|
|
_calculateMac(keyList.join(','), '${baseInfo}KEY_IDS')) {
|
|
await request.cancel('m.key_mismatch');
|
|
return;
|
|
}
|
|
|
|
if (!client.userDeviceKeys.containsKey(request.userId)) {
|
|
await request.cancel('m.key_mismatch');
|
|
return;
|
|
}
|
|
final mac = <String, String>{};
|
|
for (final entry in payload['mac'].entries) {
|
|
if (entry.value is String) {
|
|
mac[entry.key] = entry.value;
|
|
}
|
|
}
|
|
await request.verifyKeysSAS(mac, (String mac, SignableKey key) async {
|
|
return mac ==
|
|
_calculateMac(
|
|
key.ed25519Key!,
|
|
'${baseInfo}ed25519:${key.identifier!}',
|
|
);
|
|
});
|
|
}
|
|
|
|
Future<String> _makeCommitment(String pubKey, String canonicalJson) async {
|
|
if (hash == 'sha256') {
|
|
final bytes = utf8.encoder.convert(pubKey + canonicalJson);
|
|
final digest = vod.CryptoUtils.sha256(input: bytes);
|
|
return encodeBase64Unpadded(digest);
|
|
}
|
|
throw Exception('Unknown hash method');
|
|
}
|
|
|
|
String _calculateMac(String input, String info) {
|
|
if (messageAuthenticationCode == 'hkdf-hmac-sha256.v2') {
|
|
return establishedSas!.calculateMac(input, info);
|
|
} else if (messageAuthenticationCode == 'hkdf-hmac-sha256') {
|
|
return establishedSas!.calculateMacDeprecated(input, info);
|
|
} else {
|
|
throw Exception('Unknown message authentification code');
|
|
}
|
|
}
|
|
}
|
|
|
|
const _emojiMap = [
|
|
{
|
|
'emoji': '\u{1F436}',
|
|
'name': 'Dog',
|
|
},
|
|
{
|
|
'emoji': '\u{1F431}',
|
|
'name': 'Cat',
|
|
},
|
|
{
|
|
'emoji': '\u{1F981}',
|
|
'name': 'Lion',
|
|
},
|
|
{
|
|
'emoji': '\u{1F40E}',
|
|
'name': 'Horse',
|
|
},
|
|
{
|
|
'emoji': '\u{1F984}',
|
|
'name': 'Unicorn',
|
|
},
|
|
{
|
|
'emoji': '\u{1F437}',
|
|
'name': 'Pig',
|
|
},
|
|
{
|
|
'emoji': '\u{1F418}',
|
|
'name': 'Elephant',
|
|
},
|
|
{
|
|
'emoji': '\u{1F430}',
|
|
'name': 'Rabbit',
|
|
},
|
|
{
|
|
'emoji': '\u{1F43C}',
|
|
'name': 'Panda',
|
|
},
|
|
{
|
|
'emoji': '\u{1F413}',
|
|
'name': 'Rooster',
|
|
},
|
|
{
|
|
'emoji': '\u{1F427}',
|
|
'name': 'Penguin',
|
|
},
|
|
{
|
|
'emoji': '\u{1F422}',
|
|
'name': 'Turtle',
|
|
},
|
|
{
|
|
'emoji': '\u{1F41F}',
|
|
'name': 'Fish',
|
|
},
|
|
{
|
|
'emoji': '\u{1F419}',
|
|
'name': 'Octopus',
|
|
},
|
|
{
|
|
'emoji': '\u{1F98B}',
|
|
'name': 'Butterfly',
|
|
},
|
|
{
|
|
'emoji': '\u{1F337}',
|
|
'name': 'Flower',
|
|
},
|
|
{
|
|
'emoji': '\u{1F333}',
|
|
'name': 'Tree',
|
|
},
|
|
{
|
|
'emoji': '\u{1F335}',
|
|
'name': 'Cactus',
|
|
},
|
|
{
|
|
'emoji': '\u{1F344}',
|
|
'name': 'Mushroom',
|
|
},
|
|
{
|
|
'emoji': '\u{1F30F}',
|
|
'name': 'Globe',
|
|
},
|
|
{
|
|
'emoji': '\u{1F319}',
|
|
'name': 'Moon',
|
|
},
|
|
{
|
|
'emoji': '\u{2601}\u{FE0F}',
|
|
'name': 'Cloud',
|
|
},
|
|
{
|
|
'emoji': '\u{1F525}',
|
|
'name': 'Fire',
|
|
},
|
|
{
|
|
'emoji': '\u{1F34C}',
|
|
'name': 'Banana',
|
|
},
|
|
{
|
|
'emoji': '\u{1F34E}',
|
|
'name': 'Apple',
|
|
},
|
|
{
|
|
'emoji': '\u{1F353}',
|
|
'name': 'Strawberry',
|
|
},
|
|
{
|
|
'emoji': '\u{1F33D}',
|
|
'name': 'Corn',
|
|
},
|
|
{
|
|
'emoji': '\u{1F355}',
|
|
'name': 'Pizza',
|
|
},
|
|
{
|
|
'emoji': '\u{1F382}',
|
|
'name': 'Cake',
|
|
},
|
|
{
|
|
'emoji': '\u{2764}\u{FE0F}',
|
|
'name': 'Heart',
|
|
},
|
|
{
|
|
'emoji': '\u{1F600}',
|
|
'name': 'Smiley',
|
|
},
|
|
{
|
|
'emoji': '\u{1F916}',
|
|
'name': 'Robot',
|
|
},
|
|
{
|
|
'emoji': '\u{1F3A9}',
|
|
'name': 'Hat',
|
|
},
|
|
{
|
|
'emoji': '\u{1F453}',
|
|
'name': 'Glasses',
|
|
},
|
|
{
|
|
'emoji': '\u{1F527}',
|
|
'name': 'Spanner',
|
|
},
|
|
{
|
|
'emoji': '\u{1F385}',
|
|
'name': 'Santa',
|
|
},
|
|
{
|
|
'emoji': '\u{1F44D}',
|
|
'name': 'Thumbs Up',
|
|
},
|
|
{
|
|
'emoji': '\u{2602}\u{FE0F}',
|
|
'name': 'Umbrella',
|
|
},
|
|
{
|
|
'emoji': '\u{231B}',
|
|
'name': 'Hourglass',
|
|
},
|
|
{
|
|
'emoji': '\u{23F0}',
|
|
'name': 'Clock',
|
|
},
|
|
{
|
|
'emoji': '\u{1F381}',
|
|
'name': 'Gift',
|
|
},
|
|
{
|
|
'emoji': '\u{1F4A1}',
|
|
'name': 'Light Bulb',
|
|
},
|
|
{
|
|
'emoji': '\u{1F4D5}',
|
|
'name': 'Book',
|
|
},
|
|
{
|
|
'emoji': '\u{270F}\u{FE0F}',
|
|
'name': 'Pencil',
|
|
},
|
|
{
|
|
'emoji': '\u{1F4CE}',
|
|
'name': 'Paperclip',
|
|
},
|
|
{
|
|
'emoji': '\u{2702}\u{FE0F}',
|
|
'name': 'Scissors',
|
|
},
|
|
{
|
|
'emoji': '\u{1F512}',
|
|
'name': 'Lock',
|
|
},
|
|
{
|
|
'emoji': '\u{1F511}',
|
|
'name': 'Key',
|
|
},
|
|
{
|
|
'emoji': '\u{1F528}',
|
|
'name': 'Hammer',
|
|
},
|
|
{
|
|
'emoji': '\u{260E}\u{FE0F}',
|
|
'name': 'Telephone',
|
|
},
|
|
{
|
|
'emoji': '\u{1F3C1}',
|
|
'name': 'Flag',
|
|
},
|
|
{
|
|
'emoji': '\u{1F682}',
|
|
'name': 'Train',
|
|
},
|
|
{
|
|
'emoji': '\u{1F6B2}',
|
|
'name': 'Bicycle',
|
|
},
|
|
{
|
|
'emoji': '\u{2708}\u{FE0F}',
|
|
'name': 'Aeroplane',
|
|
},
|
|
{
|
|
'emoji': '\u{1F680}',
|
|
'name': 'Rocket',
|
|
},
|
|
{
|
|
'emoji': '\u{1F3C6}',
|
|
'name': 'Trophy',
|
|
},
|
|
{
|
|
'emoji': '\u{26BD}',
|
|
'name': 'Ball',
|
|
},
|
|
{
|
|
'emoji': '\u{1F3B8}',
|
|
'name': 'Guitar',
|
|
},
|
|
{
|
|
'emoji': '\u{1F3BA}',
|
|
'name': 'Trumpet',
|
|
},
|
|
{
|
|
'emoji': '\u{1F514}',
|
|
'name': 'Bell',
|
|
},
|
|
{
|
|
'emoji': '\u{2693}',
|
|
'name': 'Anchor',
|
|
},
|
|
{
|
|
'emoji': '\u{1F3A7}',
|
|
'name': 'Headphones',
|
|
},
|
|
{
|
|
'emoji': '\u{1F4C1}',
|
|
'name': 'Folder',
|
|
},
|
|
{
|
|
'emoji': '\u{1F4CC}',
|
|
'name': 'Pin',
|
|
},
|
|
];
|
|
|
|
class KeyVerificationEmoji {
|
|
final int number;
|
|
KeyVerificationEmoji(this.number);
|
|
|
|
String get emoji => _emojiMap[number]['emoji'] ?? '';
|
|
String get name => _emojiMap[number]['name'] ?? '';
|
|
}
|