import 'dart:typed_data'; import 'package:random_string/random_string.dart'; import 'package:canonical_json/canonical_json.dart'; import 'package:olm/olm.dart' as olm; import 'device_keys_list.dart'; import '../client.dart'; import '../room.dart'; /* +-------------+ +-----------+ | 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 |<--------------------------------| | | */ enum KeyVerificationState { askAccept, waitingAccept, askSas, waitingSas, done, error } List _intersect(List a, List b) { final res = []; for (final v in a) { if (b.contains(v)) { res.add(v); } } return res; } List _bytesToInt(Uint8List bytes, int totalBits) { final ret = []; var current = 0; var numBits = 0; for (final byte in bytes) { for (final bit in [7, 6, 5, 4, 3, 2, 1, 0]) { numBits++; if ((byte & (1 << bit)) > 0) { current += 1 << (totalBits - numBits); } if (numBits >= totalBits) { ret.add(current); current = 0; numBits = 0; } } } return ret; } final VERIFICATION_METHODS = [_KeyVerificationMethodSas.type]; _KeyVerificationMethod _makeVerificationMethod( String type, KeyVerification request) { if (type == _KeyVerificationMethodSas.type) { return _KeyVerificationMethodSas(request: request); } throw 'Unkown method type'; } class KeyVerification { String transactionId; final Client client; final Room room; final String userId; void Function() onUpdate; String get deviceId => _deviceId; String _deviceId; bool startedVerification = false; _KeyVerificationMethod method; List possibleMethods; Map startPaylaod; DateTime lastActivity; String lastStep; KeyVerificationState state = KeyVerificationState.waitingAccept; bool canceled = false; String canceledCode; String canceledReason; KeyVerification( {this.client, this.room, this.userId, String deviceId, this.onUpdate}) { lastActivity = DateTime.now(); _deviceId ??= deviceId; } void dispose() { print('[Key Verification] disposing object...'); method?.dispose(); } static String getTransactionId(Map payload) { return payload['transaction_id'] ?? (payload['m.relates_to'] is Map ? payload['m.relates_to']['event_id'] : null); } Future start() async { if (room == null) { transactionId = randomString(512); } await send('m.key.verification.request', { 'methods': VERIFICATION_METHODS, 'timestamp': DateTime.now().millisecondsSinceEpoch, }); startedVerification = true; setState(KeyVerificationState.waitingAccept); } Future handlePayload(String type, Map payload, [String eventId]) async { print('[Key Verification] Received type ${type}: ' + payload.toString()); try { switch (type) { case 'm.key.verification.request': _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)) { await cancel('m.timeout'); return; } // verify it has a method we can use possibleMethods = _intersect(VERIFICATION_METHODS, payload['methods']); if (possibleMethods.isEmpty) { // reject it outright await cancel('m.unknown_method'); return; } setState(KeyVerificationState.askAccept); break; case 'm.key.verification.ready': possibleMethods = _intersect(VERIFICATION_METHODS, payload['methods']); if (possibleMethods.isEmpty) { // reject it outright await cancel('m.unknown_method'); return; } // TODO: Pick method? method = _makeVerificationMethod(possibleMethods.first, this); await method.sendStart(); setState(KeyVerificationState.waitingAccept); break; case 'm.key.verification.start': _deviceId ??= payload['from_device']; transactionId ??= eventId ?? payload['transaction_id']; if (!(await verifyLastStep(['m.key.verification.request', null]))) { return; // abort } if (!VERIFICATION_METHODS.contains(payload['method'])) { await cancel('m.unknown_method'); return; } method = _makeVerificationMethod(payload['method'], this); if (lastStep == null) { if (!method.validateStart(payload)) { await cancel('m.unknown_method'); return; } startPaylaod = payload; setState(KeyVerificationState.askAccept); } else { await method.handlePayload(type, payload); } break; case 'm.key.verification.done': // do nothing break; case 'm.key.verification.cancel': canceled = true; canceledCode = payload['code']; canceledReason = payload['reason']; setState(KeyVerificationState.error); break; default: await method.handlePayload(type, payload); break; } lastStep = type; } catch (err, stacktrace) { print('[Key Verification] An error occured: ' + err.toString()); print(stacktrace); if (deviceId != null) { await cancel('m.invalid_message'); } } } /// called when the user accepts an incoming verification Future acceptVerification() async { if (!(await verifyLastStep( ['m.key.verification.request', 'm.key.verification.start']))) { return; } setState(KeyVerificationState.waitingAccept); if (lastStep == 'm.key.verification.request') { // we need to send a ready event await send('m.key.verification.ready', { 'methods': possibleMethods, }); } else { // we need to send an accept event await method.handlePayload('m.key.verification.start', startPaylaod); } } /// called when the user rejects an incoming verification Future rejectVerification() async { if (!(await verifyLastStep( ['m.key.verification.request', 'm.key.verification.start']))) { return; } await cancel('m.user'); } Future acceptSas() async { if (method is _KeyVerificationMethodSas) { await (method as _KeyVerificationMethodSas).acceptSas(); } } Future rejectSas() async { if (method is _KeyVerificationMethodSas) { await (method as _KeyVerificationMethodSas).rejectSas(); } } List get sasNumbers { if (method is _KeyVerificationMethodSas) { return _bytesToInt((method as _KeyVerificationMethodSas).makeSas(5), 13) .map((n) => n + 1000) .toList(); } return []; } List get sasTypes { if (method is _KeyVerificationMethodSas) { return (method as _KeyVerificationMethodSas).authenticationTypes; } return []; } List 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 verifyKeys(Map keys, Future Function(String, DeviceKeys) verifier) async { final verifiedDevices = []; if (!client.userDeviceKeys.containsKey(userId)) { 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; if (client.userDeviceKeys[userId].deviceKeys .containsKey(verifyDeviceId)) { if (!(await verifier(keyInfo, client.userDeviceKeys[userId].deviceKeys[verifyDeviceId]))) { await cancel('m.key_mismatch'); return; } verifiedDevices.add(verifyDeviceId); } else { // TODO: we would check here if what we are verifying is actually a // cross-signing key and not a "normal" device key } } // okay, we reached this far, so all the devices are verified! for (final verifyDeviceId in verifiedDevices) { await client.userDeviceKeys[userId].deviceKeys[verifyDeviceId] .setVerified(true, client); } } Future verifyActivity() async { if (lastActivity != null && lastActivity.add(Duration(minutes: 10)).isAfter(DateTime.now())) { lastActivity = DateTime.now(); return true; } await cancel('m.timeout'); return false; } Future verifyLastStep(List checkLastStep) async { if (!(await verifyActivity())) { return false; } if (checkLastStep.contains(lastStep)) { return true; } await cancel('m.unexpected_message'); return false; } Future cancel([String code = 'm.unknown']) async { await send('m.key.verification.cancel', { 'reason': code, 'code': code, }); canceled = true; canceledCode = code; setState(KeyVerificationState.error); } void makePayload(Map 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 send(String type, Map payload) async { makePayload(payload); print('[Key Verification] Sending type ${type}: ' + payload.toString()); print('[Key Verification] Sending to ${userId} device ${deviceId}'); if (room != null) { if (['m.key.verification.request'].contains(type)) { payload['msgtype'] = type; payload['to'] = userId; payload['body'] = 'Attempting verification request. (${type}) Apparently your client doesn\'t support this'; type = 'm.room.message'; } final newTransactionId = await room.sendEvent(payload, type: type); if (transactionId == null) { transactionId = newTransactionId; client.addKeyVerificationRequest(this); } } else { await client.sendToDevice( [client.userDeviceKeys[userId].deviceKeys[deviceId]], type, payload); } } void setState(KeyVerificationState newState) { if (state != KeyVerificationState.error) { state = newState; } if (onUpdate != null) { onUpdate(); } } } abstract class _KeyVerificationMethod { KeyVerification request; Client client; _KeyVerificationMethod({this.request}) { client = request.client; } Future handlePayload(String type, Map payload); bool validateStart(Map payload) { return false; } Future sendStart(); void dispose() {} } const KNOWN_KEY_AGREEMENT_PROTOCOLS = ['curve25519-hkdf-sha256', 'curve25519']; const KNOWN_HASHES = ['sha256']; const KNOWN_MESSAGE_AUTHENTIFICATION_CODES = ['hkdf-hmac-sha256']; const KNOWN_AUTHENTICATION_TYPES = ['emoji', 'decimal']; class _KeyVerificationMethodSas extends _KeyVerificationMethod { _KeyVerificationMethodSas({KeyVerification request}) : super(request: request); static String type = 'm.sas.v1'; String keyAgreementProtocol; String hash; String messageAuthenticationCode; List authenticationTypes; String startCanonicalJson; String commitment; String theirPublicKey; Map macPayload; olm.SAS sas; @override void dispose() { sas?.free(); } @override Future handlePayload(String type, Map payload) async { try { switch (type) { case 'm.key.verification.start': if (!(await request.verifyLastStep( ['m.key.verification.request', 'm.key.verification.start']))) { return; // abort } if (!validateStart(payload)) { await request.cancel('m.unknown_method'); return; } await _sendAccept(); break; case 'm.key.verification.accept': if (!(await request.verifyLastStep(['m.key.verification.ready']))) { return; } if (!_handleAccept(payload)) { await request.cancel('m.unknown_method'); return; } await _sendKey(); break; case 'm.key.verification.key': if (!(await request.verifyLastStep( ['m.key.verification.accept', 'm.key.verification.start']))) { return; } _handleKey(payload); if (request.lastStep == 'm.key.verification.start') { // we need to send our key await _sendKey(); } else { // we already sent our key, time to verify the commitment being valid if (!_validateCommitment()) { 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) { print('[Key Verification SAS] An error occured: ' + err.toString()); print(stacktrace); if (request.deviceId != null) { await request.cancel('m.invalid_message'); } } } Future acceptSas() async { await _sendMac(); request.setState(KeyVerificationState.waitingSas); if (macPayload != null) { await _processMac(); } } Future rejectSas() async { await request.cancel('m.mismatched_sas'); } @override Future sendStart() async { final payload = { 'method': type, 'key_agreement_protocols': KNOWN_KEY_AGREEMENT_PROTOCOLS, 'hashes': KNOWN_HASHES, 'message_authentication_codes': KNOWN_MESSAGE_AUTHENTIFICATION_CODES, 'short_authentication_string': KNOWN_AUTHENTICATION_TYPES, }; request.makePayload(payload); // We just store the canonical json in here for later verification startCanonicalJson = String.fromCharCodes(canonicalJson.encode(payload)); await request.send('m.key.verification.start', payload); } @override bool validateStart(Map payload) { if (payload['method'] != type) { return false; } final possibleKeyAgreementProtocols = _intersect( KNOWN_KEY_AGREEMENT_PROTOCOLS, payload['key_agreement_protocols']); if (possibleKeyAgreementProtocols.isEmpty) { return false; } keyAgreementProtocol = possibleKeyAgreementProtocols.first; final possibleHashes = _intersect(KNOWN_HASHES, payload['hashes']); if (possibleHashes.isEmpty) { return false; } hash = possibleHashes.first; final possibleMessageAuthenticationCodes = _intersect( KNOWN_MESSAGE_AUTHENTIFICATION_CODES, payload['message_authentication_codes']); if (possibleMessageAuthenticationCodes.isEmpty) { return false; } messageAuthenticationCode = possibleMessageAuthenticationCodes.first; final possibleAuthenticationTypes = _intersect( KNOWN_AUTHENTICATION_TYPES, payload['short_authentication_string']); if (possibleAuthenticationTypes.isEmpty) { return false; } authenticationTypes = possibleAuthenticationTypes; startCanonicalJson = String.fromCharCodes(canonicalJson.encode(payload)); return true; } Future _sendAccept() async { sas = olm.SAS(); commitment = _makeCommitment(sas.get_pubkey(), startCanonicalJson); await request.send('m.key.verification.accept', { 'method': type, 'key_agreement_protocol': keyAgreementProtocol, 'hash': hash, 'message_authentication_code': messageAuthenticationCode, 'short_authentication_string': authenticationTypes, 'commitment': commitment, }); } bool _handleAccept(Map payload) { if (!KNOWN_KEY_AGREEMENT_PROTOCOLS .contains(payload['key_agreement_protocol'])) { return false; } keyAgreementProtocol = payload['key_agreement_protocol']; if (!KNOWN_HASHES.contains(payload['hash'])) { return false; } hash = payload['hash']; if (!KNOWN_MESSAGE_AUTHENTIFICATION_CODES .contains(payload['message_authentication_code'])) { return false; } messageAuthenticationCode = payload['message_authentication_code']; final possibleAuthenticationTypes = _intersect( KNOWN_AUTHENTICATION_TYPES, payload['short_authentication_string']); if (possibleAuthenticationTypes.isEmpty) { return false; } authenticationTypes = possibleAuthenticationTypes; commitment = payload['commitment']; sas = olm.SAS(); return true; } Future _sendKey() async { await request.send('m.key.verification.key', { 'key': sas.get_pubkey(), }); } void _handleKey(Map payload) { theirPublicKey = payload['key']; sas.set_their_key(payload['key']); } bool _validateCommitment() { final checkCommitment = _makeCommitment(theirPublicKey, startCanonicalJson); return commitment == checkCommitment; } Uint8List makeSas(int bytes) { var sasInfo = ''; if (keyAgreementProtocol == 'curve25519-hkdf-sha256') { final ourInfo = '${client.userID}|${client.deviceID}|${sas.get_pubkey()}|'; 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 'Unknown key agreement protocol'; } return sas.generate_bytes(sasInfo, bytes); } Future _sendMac() async { final baseInfo = 'MATRIX_KEY_VERIFICATION_MAC' + client.userID + client.deviceID + request.userId + request.deviceId + request.transactionId; final mac = {}; final keyList = []; // 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(client.fingerprintKey, baseInfo + deviceKeyId); keyList.add(deviceKeyId); keyList.sort(); final keys = _calculateMac(keyList.join(','), baseInfo + 'KEY_IDS'); await request.send('m.key.verification.mac', { 'mac': mac, 'keys': keys, }); } Future _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 = {}; for (final entry in payload['mac'].entries) { if (entry.value is String) { mac[entry.key] = entry.value; } } await request.verifyKeys(mac, (String mac, DeviceKeys device) async { return mac == _calculateMac( device.ed25519Key, baseInfo + 'ed25519:' + device.deviceId); }); await request.send('m.key.verification.done', {}); if (request.state != KeyVerificationState.error) { request.setState(KeyVerificationState.done); } } String _makeCommitment(String pubKey, String canonicalJson) { if (hash == 'sha256') { final olmutil = olm.Utility(); final ret = olmutil.sha256(pubKey + canonicalJson); olmutil.free(); return ret; } throw 'Unknown hash method'; } String _calculateMac(String input, String info) { if (messageAuthenticationCode == 'hkdf-hmac-sha256') { return sas.calculate_mac(input, info); } else { throw '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']; }