/* * 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 . */ import 'dart:async'; import 'dart:convert'; import 'dart:core'; import 'dart:typed_data'; import 'package:base58check/base58.dart'; import 'package:crypto/crypto.dart'; import 'package:collection/collection.dart'; import 'package:matrix/encryption/utils/base64_unpadded.dart'; import '../matrix.dart'; import '../src/utils/crypto/crypto.dart' as uc; import '../src/utils/run_in_root.dart'; import 'encryption.dart'; import 'utils/ssss_cache.dart'; const cacheTypes = { EventTypes.CrossSigningSelfSigning, EventTypes.CrossSigningUserSigning, EventTypes.MegolmBackup, }; const zeroStr = '\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'; const base58Alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; const base58 = Base58Codec(base58Alphabet); const olmRecoveryKeyPrefix = [0x8B, 0x01]; const ssssKeyLength = 32; const pbkdf2DefaultIterations = 500000; const pbkdf2SaltLength = 64; /// SSSS: **S**ecure **S**ecret **S**torage and **S**haring /// Read more about SSSS at: /// https://matrix.org/docs/guides/implementing-more-advanced-e-2-ee-features-such-as-cross-signing#3-implementing-ssss class SSSS { final Encryption encryption; Client get client => encryption.client; final pendingShareRequests = {}; final _validators = Function(String)>{}; final _cacheCallbacks = Function(String)>{}; final Map _cache = {}; SSSS(this.encryption); // for testing Future clearCache() async { await client.database?.clearSSSSCache(); _cache.clear(); } static _DerivedKeys deriveKeys(Uint8List key, String name) { final zerosalt = Uint8List(8); final prk = Hmac(sha256, zerosalt).convert(key); final b = Uint8List(1); b[0] = 1; final aesKey = Hmac(sha256, prk.bytes).convert(utf8.encode(name) + b); b[0] = 2; final hmacKey = Hmac(sha256, prk.bytes).convert(aesKey.bytes + utf8.encode(name) + b); return _DerivedKeys( aesKey: Uint8List.fromList(aesKey.bytes), hmacKey: Uint8List.fromList(hmacKey.bytes)); } static Future<_Encrypted> encryptAes(String data, Uint8List key, String name, [String? ivStr]) async { Uint8List iv; if (ivStr != null) { iv = base64decodeUnpadded(ivStr); } else { iv = Uint8List.fromList(uc.secureRandomBytes(16)); } // we need to clear bit 63 of the IV iv[8] &= 0x7f; final keys = deriveKeys(key, name); final plain = Uint8List.fromList(utf8.encode(data)); final ciphertext = await uc.aesCtr.encrypt(plain, keys.aesKey, iv); final hmac = Hmac(sha256, keys.hmacKey).convert(ciphertext); return _Encrypted( iv: base64.encode(iv), ciphertext: base64.encode(ciphertext), mac: base64.encode(hmac.bytes)); } static Future decryptAes( _Encrypted data, Uint8List key, String name) async { final keys = deriveKeys(key, name); final cipher = base64decodeUnpadded(data.ciphertext); final hmac = base64 .encode(Hmac(sha256, keys.hmacKey).convert(cipher).bytes) .replaceAll(RegExp(r'=+$'), ''); if (hmac != data.mac.replaceAll(RegExp(r'=+$'), '')) { throw Exception('Bad MAC'); } final decipher = await uc.aesCtr .encrypt(cipher, keys.aesKey, base64decodeUnpadded(data.iv)); return String.fromCharCodes(decipher); } static Uint8List decodeRecoveryKey(String recoveryKey) { final result = base58.decode(recoveryKey.replaceAll(RegExp(r'\s'), '')); final parity = result.fold(0, (a, b) => a ^ b); if (parity != 0) { throw Exception('Incorrect parity'); } for (var i = 0; i < olmRecoveryKeyPrefix.length; i++) { if (result[i] != olmRecoveryKeyPrefix[i]) { throw Exception('Incorrect prefix'); } } if (result.length != olmRecoveryKeyPrefix.length + ssssKeyLength + 1) { throw Exception('Incorrect length'); } return Uint8List.fromList(result.sublist(olmRecoveryKeyPrefix.length, olmRecoveryKeyPrefix.length + ssssKeyLength)); } static String encodeRecoveryKey(Uint8List recoveryKey) { final keyToEncode = [...olmRecoveryKeyPrefix, ...recoveryKey]; final parity = keyToEncode.fold(0, (a, b) => a ^ b); keyToEncode.add(parity); // base58-encode and add a space every four chars return base58 .encode(keyToEncode) .replaceAllMapped(RegExp(r'.{4}'), (s) => '${s.group(0)} ') .trim(); } static Future keyFromPassphrase( String passphrase, PassphraseInfo info) async { if (info.algorithm != AlgorithmTypes.pbkdf2) { throw Exception('Unknown algorithm'); } if (info.iterations == null) { throw Exception('Passphrase info without iterations'); } if (info.salt == null) { throw Exception('Passphrase info without salt'); } return await uc.pbkdf2( Uint8List.fromList(utf8.encode(passphrase)), Uint8List.fromList(utf8.encode(info.salt!)), uc.sha512, info.iterations!, info.bits ?? 256); } void setValidator(String type, FutureOr Function(String) validator) { _validators[type] = validator; } void setCacheCallback(String type, FutureOr Function(String) callback) { _cacheCallbacks[type] = callback; } String? get defaultKeyId => client .accountData[EventTypes.SecretStorageDefaultKey] ?.parsedSecretStorageDefaultKeyContent .key; Future setDefaultKeyId(String keyId) async { await client.setAccountData( client.userID!, EventTypes.SecretStorageDefaultKey, SecretStorageDefaultKeyContent(key: keyId).toJson(), ); } SecretStorageKeyContent? getKey(String keyId) { return client.accountData[EventTypes.secretStorageKey(keyId)] ?.parsedSecretStorageKeyContent; } bool isKeyValid(String keyId) => getKey(keyId)?.algorithm == AlgorithmTypes.secretStorageV1AesHmcSha2; /// Creates a new secret storage key, optional encrypts it with [passphrase] /// and stores it in the user's `accountData`. Future createKey([String? passphrase]) async { Uint8List privateKey; final content = SecretStorageKeyContent(); if (passphrase != null) { // we need to derive the key off of the passphrase content.passphrase = PassphraseInfo( iterations: pbkdf2DefaultIterations, salt: base64.encode(uc.secureRandomBytes(pbkdf2SaltLength)), algorithm: AlgorithmTypes.pbkdf2, bits: ssssKeyLength * 8, ); privateKey = await client .runInBackground( _keyFromPassphrase, _KeyFromPassphraseArgs( passphrase: passphrase, info: content.passphrase!, ), ) .timeout(Duration(seconds: 10)); } else { // we need to just generate a new key from scratch privateKey = Uint8List.fromList(uc.secureRandomBytes(ssssKeyLength)); } // now that we have the private key, let's create the iv and mac final encrypted = await encryptAes(zeroStr, privateKey, ''); content.iv = encrypted.iv; content.mac = encrypted.mac; content.algorithm = AlgorithmTypes.secretStorageV1AesHmcSha2; const keyidByteLength = 24; // make sure we generate a unique key id final keyId = () sync* { for (;;) { yield base64.encode(uc.secureRandomBytes(keyidByteLength)); } }() .firstWhere((keyId) => getKey(keyId) == null); final accountDataType = EventTypes.secretStorageKey(keyId); // noooow we set the account data final waitForAccountData = client.onSync.stream.firstWhere((syncUpdate) => syncUpdate.accountData != null && syncUpdate.accountData! .any((accountData) => accountData.type == accountDataType)); await client.setAccountData( client.userID!, accountDataType, content.toJson()); await waitForAccountData; final key = open(keyId); await key.setPrivateKey(privateKey); return key; } Future checkKey(Uint8List key, SecretStorageKeyContent info) async { if (info.algorithm == AlgorithmTypes.secretStorageV1AesHmcSha2) { if ((info.mac is String) && (info.iv is String)) { final encrypted = await encryptAes(zeroStr, key, '', info.iv); return info.mac!.replaceAll(RegExp(r'=+$'), '') == encrypted.mac.replaceAll(RegExp(r'=+$'), ''); } else { // no real information about the key, assume it is valid return true; } } else { throw Exception('Unknown Algorithm'); } } bool isSecret(String type) => client.accountData[type] != null && client.accountData[type]!.content['encrypted'] is Map; Future getCached(String type) async { if (client.database == null) { return null; } // check if it is still valid final keys = keyIdsFromType(type); if (keys == null) { return null; } final isValid = (dbEntry) => keys.contains(dbEntry.keyId) && dbEntry.ciphertext != null && client.accountData[type]?.content['encrypted'][dbEntry.keyId] ['ciphertext'] == dbEntry.ciphertext; if (_cache.containsKey(type) && isValid(_cache[type])) { return _cache[type]?.content; } final ret = await client.database?.getSSSSCache(type); if (ret == null) { return null; } if (isValid(ret)) { _cache[type] = ret; return ret.content; } return null; } Future getStored(String type, String keyId, Uint8List key) async { final secretInfo = client.accountData[type]; if (secretInfo == null) { throw Exception('Not found'); } if (!(secretInfo.content['encrypted'] is Map)) { throw Exception('Content is not encrypted'); } if (!(secretInfo.content['encrypted'][keyId] is Map)) { throw Exception('Wrong / unknown key'); } final enc = secretInfo.content['encrypted'][keyId]; final encryptInfo = _Encrypted( iv: enc['iv'], ciphertext: enc['ciphertext'], mac: enc['mac']); final decrypted = await decryptAes(encryptInfo, key, type); final db = client.database; if (cacheTypes.contains(type) && db != null) { // cache the thing await db.storeSSSSCache(type, keyId, enc['ciphertext'], decrypted); if (_cacheCallbacks.containsKey(type) && await getCached(type) == null) { _cacheCallbacks[type]!(decrypted); } } return decrypted; } Future store(String type, String secret, String keyId, Uint8List key, {bool add = false}) async { final encrypted = await encryptAes(secret, key, type); Map? content; if (add && client.accountData[type] != null) { content = client.accountData[type]!.content.copy(); if (!(content['encrypted'] is Map)) { content['encrypted'] = {}; } } content ??= { 'encrypted': {}, }; content['encrypted'][keyId] = { 'iv': encrypted.iv, 'ciphertext': encrypted.ciphertext, 'mac': encrypted.mac, }; // store the thing in your account data await client.setAccountData(client.userID!, type, content); final db = client.database; if (cacheTypes.contains(type) && db != null) { // cache the thing await db.storeSSSSCache(type, keyId, encrypted.ciphertext, secret); if (_cacheCallbacks.containsKey(type) && await getCached(type) == null) { _cacheCallbacks[type]!(secret); } } } Future validateAndStripOtherKeys( String type, String secret, String keyId, Uint8List key) async { if (await getStored(type, keyId, key) != secret) { throw Exception('Secrets do not match up!'); } // now remove all other keys final content = client.accountData[type]?.content.copy(); if (content == null) { throw Exception('Key has no content!'); } final otherKeys = Set.from(content['encrypted'].keys.where((k) => k != keyId)); content['encrypted'].removeWhere((k, v) => otherKeys.contains(k)); // yes, we are paranoid... if (await getStored(type, keyId, key) != secret) { throw Exception('Secrets do not match up!'); } // store the thing in your account data await client.setAccountData(client.userID!, type, content); if (cacheTypes.contains(type)) { // cache the thing await client.database?.storeSSSSCache( type, keyId, content['encrypted'][keyId]['ciphertext'], secret); } } Future maybeCacheAll(String keyId, Uint8List key) async { for (final type in cacheTypes) { final secret = await getCached(type); if (secret == null) { try { await getStored(type, keyId, key); } catch (_) { // the entry wasn't stored, just ignore it } } } } Future maybeRequestAll([List? devices]) async { for (final type in cacheTypes) { if (keyIdsFromType(type) != null) { final secret = await getCached(type); if (secret == null) { await request(type, devices); } } } } Future request(String type, [List? devices]) async { // only send to own, verified devices Logs().i('[SSSS] Requesting type $type...'); if (devices == null || devices.isEmpty) { if (!client.userDeviceKeys.containsKey(client.userID)) { Logs().w('[SSSS] User does not have any devices'); return; } devices = client.userDeviceKeys[client.userID]!.deviceKeys.values.toList(); } devices.removeWhere((DeviceKeys d) => d.userId != client.userID || !d.verified || d.blocked || d.deviceId == client.deviceID); if (devices.isEmpty) { Logs().w('[SSSS] No devices'); return; } final requestId = client.generateUniqueTransactionId(); final request = _ShareRequest( requestId: requestId, type: type, devices: devices, ); pendingShareRequests[requestId] = request; await client.sendToDeviceEncrypted(devices, EventTypes.SecretRequest, { 'action': 'request', 'requesting_device_id': client.deviceID, 'request_id': requestId, 'name': type, }); } DateTime? _lastCacheRequest; bool _isPeriodicallyRequestingMissingCache = false; Future periodicallyRequestMissingCache() async { if (_isPeriodicallyRequestingMissingCache || (_lastCacheRequest != null && DateTime.now() .subtract(Duration(minutes: 15)) .isBefore(_lastCacheRequest!)) || client.isUnknownSession) { // we are already requesting right now or we attempted to within the last 15 min return; } _lastCacheRequest = DateTime.now(); _isPeriodicallyRequestingMissingCache = true; try { await maybeRequestAll(); } finally { _isPeriodicallyRequestingMissingCache = false; } } Future handleToDeviceEvent(ToDeviceEvent event) async { if (event.type == EventTypes.SecretRequest) { // got a request to share a secret Logs().i('[SSSS] Received sharing request...'); if (event.sender != client.userID || !client.userDeviceKeys.containsKey(client.userID)) { Logs().i('[SSSS] Not sent by us'); return; // we aren't asking for it ourselves, so ignore } if (event.content['action'] != 'request') { Logs().i('[SSSS] it is actually a cancelation'); return; // not actually requesting, so ignore } final device = client.userDeviceKeys[client.userID]! .deviceKeys[event.content['requesting_device_id']]; if (device == null || !device.verified || device.blocked) { Logs().i('[SSSS] Unknown / unverified devices, ignoring'); return; // nope....unknown or untrusted device } // alright, all seems fine...let's check if we actually have the secret they are asking for final type = event.content['name']; final secret = await getCached(type); if (secret == null) { Logs() .i('[SSSS] We don\'t have the secret for $type ourself, ignoring'); return; // seems like we don't have this, either } // okay, all checks out...time to share this secret! Logs().i('[SSSS] Replying with secret for $type'); await client.sendToDeviceEncrypted( [device], EventTypes.SecretSend, { 'request_id': event.content['request_id'], 'secret': secret, }); } else if (event.type == EventTypes.SecretSend) { // receiving a secret we asked for Logs().i('[SSSS] Received shared secret...'); final encryptedContent = event.encryptedContent; if (event.sender != client.userID || !pendingShareRequests.containsKey(event.content['request_id']) || encryptedContent == null) { Logs().i('[SSSS] Not by us or unknown request'); return; // we have no idea what we just received } final request = pendingShareRequests[event.content['request_id']]!; // alright, as we received a known request id, let's check if the sender is valid final device = request.devices.firstWhereOrNull((d) => d.userId == event.sender && d.curve25519Key == encryptedContent['sender_key']); if (device == null) { Logs().i('[SSSS] Someone else replied?'); return; // someone replied whom we didn't send the share request to } final secret = event.content['secret']; if (!(event.content['secret'] is String)) { Logs().i('[SSSS] Secret wasn\'t a string'); return; // the secret wasn't a string....wut? } // let's validate if the secret is, well, valid if (_validators.containsKey(request.type) && !(await _validators[request.type]!(secret))) { Logs().i('[SSSS] The received secret was invalid'); return; // didn't pass the validator } pendingShareRequests.remove(request.requestId); if (request.start.add(Duration(minutes: 15)).isBefore(DateTime.now())) { Logs().i('[SSSS] Request is too far in the past'); return; // our request is more than 15min in the past...better not trust it anymore } Logs().i('[SSSS] Secret for type ${request.type} is ok, storing it'); final db = client.database; if (db != null) { final keyId = keyIdFromType(request.type); if (keyId != null) { final ciphertext = client.accountData[request.type]! .content['encrypted'][keyId]['ciphertext']; await db.storeSSSSCache(request.type, keyId, ciphertext, secret); if (_cacheCallbacks.containsKey(request.type)) { _cacheCallbacks[request.type]!(secret); } } } } } Set? keyIdsFromType(String type) { final data = client.accountData[type]; if (data == null) { return null; } if (data.content['encrypted'] is Map) { return data.content['encrypted'].keys.toSet(); } return null; } String? keyIdFromType(String type) { final keys = keyIdsFromType(type); if (keys == null || keys.isEmpty) { return null; } if (keys.contains(defaultKeyId)) { return defaultKeyId; } return keys.first; } OpenSSSS open([String? identifier]) { identifier ??= defaultKeyId; if (identifier == null) { throw Exception('Dont know what to open'); } final keyToOpen = keyIdFromType(identifier) ?? identifier; final key = getKey(keyToOpen); if (key == null) { throw Exception('Unknown key to open'); } return OpenSSSS(ssss: this, keyId: keyToOpen, keyData: key); } } class _ShareRequest { final String requestId; final String type; final List devices; final DateTime start; _ShareRequest( {required this.requestId, required this.type, required this.devices}) : start = DateTime.now(); } class _Encrypted { final String iv; final String ciphertext; final String mac; _Encrypted({required this.iv, required this.ciphertext, required this.mac}); } class _DerivedKeys { final Uint8List aesKey; final Uint8List hmacKey; _DerivedKeys({required this.aesKey, required this.hmacKey}); } class OpenSSSS { final SSSS ssss; final String keyId; final SecretStorageKeyContent keyData; OpenSSSS({required this.ssss, required this.keyId, required this.keyData}); Uint8List? privateKey; bool get isUnlocked => privateKey != null; bool get hasPassphrase => keyData.passphrase != null; String? get recoveryKey => isUnlocked ? SSSS.encodeRecoveryKey(privateKey!) : null; Future unlock( {String? passphrase, String? recoveryKey, String? keyOrPassphrase, bool postUnlock = true}) async { if (keyOrPassphrase != null) { try { await unlock(recoveryKey: keyOrPassphrase, postUnlock: postUnlock); } catch (_) { if (hasPassphrase) { await unlock(passphrase: keyOrPassphrase, postUnlock: postUnlock); } else { rethrow; } } return; } else if (passphrase != null) { if (!hasPassphrase) { throw Exception( 'Tried to unlock with passphrase while key does not have a passphrase'); } privateKey = await ssss.client .runInBackground( _keyFromPassphrase, _KeyFromPassphraseArgs( passphrase: passphrase, info: keyData.passphrase!, ), ) .timeout(Duration(seconds: 10)); } else if (recoveryKey != null) { privateKey = SSSS.decodeRecoveryKey(recoveryKey); } else { throw Exception('Nothing specified'); } // verify the validity of the key if (!await ssss.checkKey(privateKey!, keyData)) { privateKey = null; throw Exception('Inalid key'); } if (postUnlock) { await runInRoot(() => _postUnlock()); } } Future setPrivateKey(Uint8List key) async { if (!await ssss.checkKey(key, keyData)) { throw Exception('Invalid key'); } privateKey = key; } Future getStored(String type) async { final privateKey = this.privateKey; if (privateKey == null) { throw Exception('SSSS not unlocked'); } return await ssss.getStored(type, keyId, privateKey); } Future store(String type, String secret, {bool add = false}) async { final privateKey = this.privateKey; if (privateKey == null) { throw Exception('SSSS not unlocked'); } await ssss.store(type, secret, keyId, privateKey, add: add); } Future validateAndStripOtherKeys(String type, String secret) async { final privateKey = this.privateKey; if (privateKey == null) { throw Exception('SSSS not unlocked'); } await ssss.validateAndStripOtherKeys(type, secret, keyId, privateKey); } Future maybeCacheAll() async { final privateKey = this.privateKey; if (privateKey == null) { throw Exception('SSSS not unlocked'); } await ssss.maybeCacheAll(keyId, privateKey); } Future _postUnlock() async { // first try to cache all secrets that aren't cached yet await maybeCacheAll(); // now try to self-sign if (ssss.encryption.crossSigning.enabled && ssss.client.userDeviceKeys[ssss.client.userID]?.masterKey != null && (ssss .keyIdsFromType(EventTypes.CrossSigningMasterKey) ?.contains(keyId) ?? false) && (ssss.client.isUnknownSession || ssss.client.userDeviceKeys[ssss.client.userID]!.masterKey ?.directVerified != true)) { try { await ssss.encryption.crossSigning.selfSign(openSsss: this); } catch (e, s) { Logs().e('[SSSS] Failed to self-sign', e, s); } } } } class _KeyFromPassphraseArgs { final String passphrase; final PassphraseInfo info; _KeyFromPassphraseArgs({required this.passphrase, required this.info}); } Future _keyFromPassphrase(_KeyFromPassphraseArgs args) async { return await SSSS.keyFromPassphrase(args.passphrase, args.info); }