diff --git a/lib/main.dart b/lib/main.dart index 426fadbe..4f65052b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,6 +12,7 @@ import 'package:twonly/core/frb_generated.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/callbacks/callbacks.dart'; +import 'package:twonly/src/database/tables/contacts.table.dart'; import 'package:twonly/src/providers/connection.provider.dart'; import 'package:twonly/src/providers/image_editor.provider.dart'; import 'package:twonly/src/providers/purchases.provider.dart'; @@ -121,15 +122,25 @@ Future runMigrations() async { if (userService.currentUser.appVersion < 90) { // BUG: Requested media files for reupload where not reuploaded because the wrong state... await twonlyDB.mediaFilesDao.updateAllRetransmissionUploadingState(); - await updateUser((u) { - u.appVersion = 90; - }); + await updateUser((u) => u.appVersion = 90); } + if (userService.currentUser.appVersion < 91) { // BUG: Requested media files for reupload where not reuploaded because the wrong state... await makeMigrationToVersion91(); - await updateUser((u) { - u.appVersion = 91; - }); + await updateUser((u) => u.appVersion = 91); + } + + if (userService.currentUser.appVersion < 109) { + final contacts = await twonlyDB.contactsDao.getAllContacts(); + for (final contact in contacts) { + if (contact.verified) { + await twonlyDB.keyVerificationDao.addKeyVerification( + contact.userId, + VerificationType.migratedFromOldVersion, + ); + } + } + await updateUser((u) => u.appVersion = 109); } } diff --git a/lib/src/database/daos/contacts.dao.dart b/lib/src/database/daos/contacts.dao.dart index c0baab18..81bc5d62 100644 --- a/lib/src/database/daos/contacts.dao.dart +++ b/lib/src/database/daos/contacts.dao.dart @@ -7,7 +7,7 @@ import 'package:twonly/src/utils/log.dart'; part 'contacts.dao.g.dart'; -@DriftAccessor(tables: [Contacts]) +@DriftAccessor(tables: [Contacts, KeyVerifications]) class ContactsDao extends DatabaseAccessor with _$ContactsDaoMixin { // this constructor is required so that the main database can create an instance // of this object. @@ -99,6 +99,21 @@ class ContactsDao extends DatabaseAccessor with _$ContactsDaoMixin { )..where((t) => t.userId.equals(userid))).watchSingleOrNull(); } + Stream<(Contact, bool)?> watchContactAndVerificationState(int userid) { + final query = (select(contacts)..where((t) => t.userId.equals(userid))).join([ + leftOuterJoin( + keyVerifications, + keyVerifications.contactId.equalsExp(contacts.userId), + ), + ]); + return query + .map((row) => ( + row.readTable(contacts), + row.readTableOrNull(keyVerifications) != null, + )) + .watchSingleOrNull(); + } + Future> getAllContacts() { return select(contacts).get(); } diff --git a/lib/src/database/daos/contacts.dao.g.dart b/lib/src/database/daos/contacts.dao.g.dart index f76b6079..b5ce2db1 100644 --- a/lib/src/database/daos/contacts.dao.g.dart +++ b/lib/src/database/daos/contacts.dao.g.dart @@ -5,6 +5,8 @@ part of 'contacts.dao.dart'; // ignore_for_file: type=lint mixin _$ContactsDaoMixin on DatabaseAccessor { $ContactsTable get contacts => attachedDatabase.contacts; + $KeyVerificationsTable get keyVerifications => + attachedDatabase.keyVerifications; ContactsDaoManager get managers => ContactsDaoManager(this); } @@ -13,4 +15,9 @@ class ContactsDaoManager { ContactsDaoManager(this._db); $$ContactsTableTableManager get contacts => $$ContactsTableTableManager(_db.attachedDatabase, _db.contacts); + $$KeyVerificationsTableTableManager get keyVerifications => + $$KeyVerificationsTableTableManager( + _db.attachedDatabase, + _db.keyVerifications, + ); } diff --git a/lib/src/database/daos/key_verification.dao.dart b/lib/src/database/daos/key_verification.dao.dart new file mode 100644 index 00000000..b71ac5dc --- /dev/null +++ b/lib/src/database/daos/key_verification.dao.dart @@ -0,0 +1,81 @@ +import 'package:drift/drift.dart'; +import 'package:twonly/src/database/tables/contacts.table.dart'; +import 'package:twonly/src/database/tables/groups.table.dart'; +import 'package:twonly/src/database/twonly.db.dart'; + +part 'key_verification.dao.g.dart'; + +@DriftAccessor( + tables: [Contacts, VerificationTokens, KeyVerifications, GroupMembers], +) +class KeyVerificationDao extends DatabaseAccessor + with _$KeyVerificationDaoMixin { + // ignore: matching_super_parameters + KeyVerificationDao(super.db); + + Future> getRecentVerificationTokens() { + final cutoff = DateTime.now().subtract(const Duration(hours: 24)); + return (select( + verificationTokens, + )..where((t) => t.createdAt.isBiggerOrEqualValue(cutoff))).get(); + } + + Future insertVerificationToken(Uint8List token) { + return into(verificationTokens).insert( + VerificationTokensCompanion.insert(token: token), + ); + } + + /// Returns a map of contactId → the verification type of the earliest + /// [KeyVerification] row for that contact. + Future> + getFirstVerificationTypeByContacts() async { + final rows = await (select( + keyVerifications, + )..orderBy([(kv) => OrderingTerm.asc(kv.createdAt)])).get(); + + final result = {}; + for (final row in rows) { + result.putIfAbsent(row.contactId, () => row.type); + } + return result; + } + + Future isContactVerified(int contactId) async { + final row = + await (select(keyVerifications) + ..where((kv) => kv.contactId.equals(contactId)) + ..limit(1)) + .getSingleOrNull(); + return row != null; + } + + Stream> watchContactVerification(int contactId) { + return (select( + keyVerifications, + )..where((kv) => kv.contactId.equals(contactId))).watch(); + } + + Stream watchAllGroupMembersVerified(String groupId) { + final gm = groupMembers; + final kv = keyVerifications; + + final query = (select(gm)..where((m) => m.groupId.equals(groupId))).join([ + leftOuterJoin(kv, kv.contactId.equalsExp(gm.contactId)), + ]); + + return query.watch().map( + (rows) => + rows.isNotEmpty && rows.every((r) => r.readTableOrNull(kv) != null), + ); + } + + Future addKeyVerification(int contactId, VerificationType type) async { + await into(keyVerifications).insertOnConflictUpdate( + KeyVerificationsCompanion( + contactId: Value(contactId), + type: Value(type), + ), + ); + } +} diff --git a/lib/src/database/daos/key_verification.dao.g.dart b/lib/src/database/daos/key_verification.dao.g.dart new file mode 100644 index 00000000..6d69eb21 --- /dev/null +++ b/lib/src/database/daos/key_verification.dao.g.dart @@ -0,0 +1,36 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'key_verification.dao.dart'; + +// ignore_for_file: type=lint +mixin _$KeyVerificationDaoMixin on DatabaseAccessor { + $ContactsTable get contacts => attachedDatabase.contacts; + $VerificationTokensTable get verificationTokens => + attachedDatabase.verificationTokens; + $KeyVerificationsTable get keyVerifications => + attachedDatabase.keyVerifications; + $GroupsTable get groups => attachedDatabase.groups; + $GroupMembersTable get groupMembers => attachedDatabase.groupMembers; + KeyVerificationDaoManager get managers => KeyVerificationDaoManager(this); +} + +class KeyVerificationDaoManager { + final _$KeyVerificationDaoMixin _db; + KeyVerificationDaoManager(this._db); + $$ContactsTableTableManager get contacts => + $$ContactsTableTableManager(_db.attachedDatabase, _db.contacts); + $$VerificationTokensTableTableManager get verificationTokens => + $$VerificationTokensTableTableManager( + _db.attachedDatabase, + _db.verificationTokens, + ); + $$KeyVerificationsTableTableManager get keyVerifications => + $$KeyVerificationsTableTableManager( + _db.attachedDatabase, + _db.keyVerifications, + ); + $$GroupsTableTableManager get groups => + $$GroupsTableTableManager(_db.attachedDatabase, _db.groups); + $$GroupMembersTableTableManager get groupMembers => + $$GroupMembersTableTableManager(_db.attachedDatabase, _db.groupMembers); +} diff --git a/lib/src/database/tables/contacts.table.dart b/lib/src/database/tables/contacts.table.dart index b510dce8..315d6618 100644 --- a/lib/src/database/tables/contacts.table.dart +++ b/lib/src/database/tables/contacts.table.dart @@ -38,8 +38,11 @@ class Contacts extends Table { } enum VerificationType { - qr, + migratedFromOldVersion, + qrScanned, link, + secretQrToken, + contactSharedByVerified, } @DataClassName('KeyVerification') diff --git a/lib/src/database/twonly.db.dart b/lib/src/database/twonly.db.dart index 0605d560..fdd83193 100644 --- a/lib/src/database/twonly.db.dart +++ b/lib/src/database/twonly.db.dart @@ -5,6 +5,7 @@ import 'package:drift_flutter/drift_flutter.dart' import 'package:path_provider/path_provider.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/daos/groups.dao.dart'; +import 'package:twonly/src/database/daos/key_verification.dao.dart'; import 'package:twonly/src/database/daos/mediafiles.dao.dart'; import 'package:twonly/src/database/daos/messages.dao.dart'; import 'package:twonly/src/database/daos/reactions.dao.dart'; @@ -60,6 +61,7 @@ part 'twonly.db.g.dart'; ReactionsDao, MediaFilesDao, UserDiscoveryDao, + KeyVerificationDao, ], ) class TwonlyDB extends _$TwonlyDB { diff --git a/lib/src/database/twonly.db.g.dart b/lib/src/database/twonly.db.g.dart index b9a7bddf..929d176c 100644 --- a/lib/src/database/twonly.db.g.dart +++ b/lib/src/database/twonly.db.g.dart @@ -11374,6 +11374,9 @@ abstract class _$TwonlyDB extends GeneratedDatabase { late final UserDiscoveryDao userDiscoveryDao = UserDiscoveryDao( this as TwonlyDB, ); + late final KeyVerificationDao keyVerificationDao = KeyVerificationDao( + this as TwonlyDB, + ); @override Iterable> get allTables => allSchemaEntities.whereType>(); diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index afda143b..032918b8 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -970,6 +970,42 @@ abstract class AppLocalizations { /// **'Clear verification'** String get contactVerifyNumberClearVerification; + /// No description provided for @userVerifiedTitle. + /// + /// In en, this message translates to: + /// **'User verified'** + String get userVerifiedTitle; + + /// No description provided for @verificationTypeQrScanned. + /// + /// In en, this message translates to: + /// **'You scanned their QR code.'** + String get verificationTypeQrScanned; + + /// No description provided for @verificationTypeSecretQrToken. + /// + /// In en, this message translates to: + /// **'The other person scanned your QR code.'** + String get verificationTypeSecretQrToken; + + /// No description provided for @verificationTypeLink. + /// + /// In en, this message translates to: + /// **'Verified via link.'** + String get verificationTypeLink; + + /// No description provided for @verificationTypeContactSharedByVerified. + /// + /// In en, this message translates to: + /// **'Contact received from a verified contact.'** + String get verificationTypeContactSharedByVerified; + + /// No description provided for @verificationTypeMigratedFromOldVersion. + /// + /// In en, this message translates to: + /// **'Migrated from old version.'** + String get verificationTypeMigratedFromOldVersion; + /// No description provided for @contactVerifyNumberLongDesc. /// /// In en, this message translates to: diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index 33e14ff5..b236421f 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -482,6 +482,27 @@ class AppLocalizationsDe extends AppLocalizations { @override String get contactVerifyNumberClearVerification => 'Verifizierung aufheben'; + @override + String get userVerifiedTitle => 'Benutzer verifiziert'; + + @override + String get verificationTypeQrScanned => 'Du hast den QR-Code gescannt.'; + + @override + String get verificationTypeSecretQrToken => + 'Die andere Person hat deinen QR-Code gescannt.'; + + @override + String get verificationTypeLink => 'Per Link verifiziert.'; + + @override + String get verificationTypeContactSharedByVerified => + 'Von einem verifizierten Kontakt geteilt bekommen.'; + + @override + String get verificationTypeMigratedFromOldVersion => + 'Von alter Version migriert'; + @override String contactVerifyNumberLongDesc(Object username) { return 'Um die Ende-zu-Ende-Verschlüsselung mit $username zu verifizieren, vergleiche die Zahlen mit deren Gerät. Die Person kann auch deinen Code mit deren Gerät scannen.'; diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index 095695fd..006dc7af 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -477,6 +477,27 @@ class AppLocalizationsEn extends AppLocalizations { @override String get contactVerifyNumberClearVerification => 'Clear verification'; + @override + String get userVerifiedTitle => 'User verified'; + + @override + String get verificationTypeQrScanned => 'You scanned their QR code.'; + + @override + String get verificationTypeSecretQrToken => + 'The other person scanned your QR code.'; + + @override + String get verificationTypeLink => 'Verified via link.'; + + @override + String get verificationTypeContactSharedByVerified => + 'Contact received from a verified contact.'; + + @override + String get verificationTypeMigratedFromOldVersion => + 'Migrated from old version.'; + @override String contactVerifyNumberLongDesc(Object username) { return 'To verify the end-to-end encryption with $username, compare the numbers with their device. The person can also scan your code with their device.'; diff --git a/lib/src/localization/generated/app_localizations_sv.dart b/lib/src/localization/generated/app_localizations_sv.dart index 2dc1d800..aaad51cd 100644 --- a/lib/src/localization/generated/app_localizations_sv.dart +++ b/lib/src/localization/generated/app_localizations_sv.dart @@ -477,6 +477,27 @@ class AppLocalizationsSv extends AppLocalizations { @override String get contactVerifyNumberClearVerification => 'Clear verification'; + @override + String get userVerifiedTitle => 'User verified'; + + @override + String get verificationTypeQrScanned => 'You scanned their QR code.'; + + @override + String get verificationTypeSecretQrToken => + 'The other person scanned your QR code.'; + + @override + String get verificationTypeLink => 'Verified via link.'; + + @override + String get verificationTypeContactSharedByVerified => + 'Contact received from a verified contact.'; + + @override + String get verificationTypeMigratedFromOldVersion => + 'Migrated from old version.'; + @override String contactVerifyNumberLongDesc(Object username) { return 'To verify the end-to-end encryption with $username, compare the numbers with their device. The person can also scan your code with their device.'; diff --git a/lib/src/localization/translations b/lib/src/localization/translations index 57ec5129..a35f6a65 160000 --- a/lib/src/localization/translations +++ b/lib/src/localization/translations @@ -1 +1 @@ -Subproject commit 57ec512977e514fca6413622bb4a7e03701f09a0 +Subproject commit a35f6a65cf87db6e1b48dea1c0260b59b52b21f1 diff --git a/lib/src/model/protobuf/client/generated/messages.pb.dart b/lib/src/model/protobuf/client/generated/messages.pb.dart index dea973ea..4de0f5e4 100644 --- a/lib/src/model/protobuf/client/generated/messages.pb.dart +++ b/lib/src/model/protobuf/client/generated/messages.pb.dart @@ -1857,6 +1857,68 @@ class EncryptedContent_UserDiscoveryUpdate extends $pb.GeneratedMessage { $pb.PbList<$core.List<$core.int>> get messages => $_getList(0); } +class EncryptedContent_KeyVerificationProof extends $pb.GeneratedMessage { + factory EncryptedContent_KeyVerificationProof({ + $core.List<$core.int>? calculatedMac, + }) { + final result = create(); + if (calculatedMac != null) result.calculatedMac = calculatedMac; + return result; + } + + EncryptedContent_KeyVerificationProof._(); + + factory EncryptedContent_KeyVerificationProof.fromBuffer( + $core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory EncryptedContent_KeyVerificationProof.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'EncryptedContent.KeyVerificationProof', + createEmptyInstance: create) + ..a<$core.List<$core.int>>( + 1, _omitFieldNames ? '' : 'calculatedMac', $pb.PbFieldType.OY) + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + EncryptedContent_KeyVerificationProof clone() => + EncryptedContent_KeyVerificationProof()..mergeFromMessage(this); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + EncryptedContent_KeyVerificationProof copyWith( + void Function(EncryptedContent_KeyVerificationProof) updates) => + super.copyWith((message) => + updates(message as EncryptedContent_KeyVerificationProof)) + as EncryptedContent_KeyVerificationProof; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static EncryptedContent_KeyVerificationProof create() => + EncryptedContent_KeyVerificationProof._(); + @$core.override + EncryptedContent_KeyVerificationProof createEmptyInstance() => create(); + static $pb.PbList createRepeated() => + $pb.PbList(); + @$core.pragma('dart2js:noInline') + static EncryptedContent_KeyVerificationProof getDefault() => + _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor< + EncryptedContent_KeyVerificationProof>(create); + static EncryptedContent_KeyVerificationProof? _defaultInstance; + + @$pb.TagNumber(1) + $core.List<$core.int> get calculatedMac => $_getN(0); + @$pb.TagNumber(1) + set calculatedMac($core.List<$core.int> value) => $_setBytes(0, value); + @$pb.TagNumber(1) + $core.bool hasCalculatedMac() => $_has(0); + @$pb.TagNumber(1) + void clearCalculatedMac() => $_clearField(1); +} + class EncryptedContent extends $pb.GeneratedMessage { factory EncryptedContent({ $core.String? groupId, @@ -1881,6 +1943,7 @@ class EncryptedContent extends $pb.GeneratedMessage { $core.List<$core.int>? senderUserDiscoveryVersion, EncryptedContent_UserDiscoveryRequest? userDiscoveryRequest, EncryptedContent_UserDiscoveryUpdate? userDiscoveryUpdate, + EncryptedContent_KeyVerificationProof? keyVerificationProof, }) { final result = create(); if (groupId != null) result.groupId = groupId; @@ -1911,6 +1974,8 @@ class EncryptedContent extends $pb.GeneratedMessage { result.userDiscoveryRequest = userDiscoveryRequest; if (userDiscoveryUpdate != null) result.userDiscoveryUpdate = userDiscoveryUpdate; + if (keyVerificationProof != null) + result.keyVerificationProof = keyVerificationProof; return result; } @@ -1979,6 +2044,9 @@ class EncryptedContent extends $pb.GeneratedMessage { ..aOM( 23, _omitFieldNames ? '' : 'userDiscoveryUpdate', subBuilder: EncryptedContent_UserDiscoveryUpdate.create) + ..aOM( + 24, _omitFieldNames ? '' : 'keyVerificationProof', + subBuilder: EncryptedContent_KeyVerificationProof.create) ..hasRequiredFields = false; @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @@ -2251,6 +2319,19 @@ class EncryptedContent extends $pb.GeneratedMessage { @$pb.TagNumber(23) EncryptedContent_UserDiscoveryUpdate ensureUserDiscoveryUpdate() => $_ensure(21); + + @$pb.TagNumber(24) + EncryptedContent_KeyVerificationProof get keyVerificationProof => $_getN(22); + @$pb.TagNumber(24) + set keyVerificationProof(EncryptedContent_KeyVerificationProof value) => + $_setField(24, value); + @$pb.TagNumber(24) + $core.bool hasKeyVerificationProof() => $_has(22); + @$pb.TagNumber(24) + void clearKeyVerificationProof() => $_clearField(24); + @$pb.TagNumber(24) + EncryptedContent_KeyVerificationProof ensureKeyVerificationProof() => + $_ensure(22); } const $core.bool _omitFieldNames = diff --git a/lib/src/model/protobuf/client/generated/messages.pbjson.dart b/lib/src/model/protobuf/client/generated/messages.pbjson.dart index b29eb4d1..55e412f9 100644 --- a/lib/src/model/protobuf/client/generated/messages.pbjson.dart +++ b/lib/src/model/protobuf/client/generated/messages.pbjson.dart @@ -365,6 +365,16 @@ const EncryptedContent$json = { '10': 'userDiscoveryUpdate', '17': true }, + { + '1': 'key_verification_proof', + '3': 24, + '4': 1, + '5': 11, + '6': '.EncryptedContent.KeyVerificationProof', + '9': 22, + '10': 'keyVerificationProof', + '17': true + }, ], '3': [ EncryptedContent_ErrorMessages$json, @@ -384,7 +394,8 @@ const EncryptedContent$json = { EncryptedContent_FlameSync$json, EncryptedContent_TypingIndicator$json, EncryptedContent_UserDiscoveryRequest$json, - EncryptedContent_UserDiscoveryUpdate$json + EncryptedContent_UserDiscoveryUpdate$json, + EncryptedContent_KeyVerificationProof$json ], '8': [ {'1': '_group_id'}, @@ -409,6 +420,7 @@ const EncryptedContent$json = { {'1': '_typing_indicator'}, {'1': '_user_discovery_request'}, {'1': '_user_discovery_update'}, + {'1': '_key_verification_proof'}, ], }; @@ -911,6 +923,14 @@ const EncryptedContent_UserDiscoveryUpdate$json = { ], }; +@$core.Deprecated('Use encryptedContentDescriptor instead') +const EncryptedContent_KeyVerificationProof$json = { + '1': 'KeyVerificationProof', + '2': [ + {'1': 'calculated_mac', '3': 1, '4': 1, '5': 12, '10': 'calculatedMac'}, + ], +}; + /// Descriptor for `EncryptedContent`. Decode as a `google.protobuf.DescriptorProto`. final $typed_data.Uint8List encryptedContentDescriptor = $convert.base64Decode( 'ChBFbmNyeXB0ZWRDb250ZW50Eh4KCGdyb3VwX2lkGAIgASgJSABSB2dyb3VwSWSIAQESKQoOaX' @@ -941,75 +961,79 @@ final $typed_data.Uint8List encryptedContentDescriptor = $convert.base64Decode( 'gTUg90eXBpbmdJbmRpY2F0b3KIAQESYQoWdXNlcl9kaXNjb3ZlcnlfcmVxdWVzdBgWIAEoCzIm' 'LkVuY3J5cHRlZENvbnRlbnQuVXNlckRpc2NvdmVyeVJlcXVlc3RIFFIUdXNlckRpc2NvdmVyeV' 'JlcXVlc3SIAQESXgoVdXNlcl9kaXNjb3ZlcnlfdXBkYXRlGBcgASgLMiUuRW5jcnlwdGVkQ29u' - 'dGVudC5Vc2VyRGlzY292ZXJ5VXBkYXRlSBVSE3VzZXJEaXNjb3ZlcnlVcGRhdGWIAQEa8AEKDU' - 'Vycm9yTWVzc2FnZXMSOAoEdHlwZRgBIAEoDjIkLkVuY3J5cHRlZENvbnRlbnQuRXJyb3JNZXNz' - 'YWdlcy5UeXBlUgR0eXBlEiwKEnJlbGF0ZWRfcmVjZWlwdF9pZBgCIAEoCVIQcmVsYXRlZFJlY2' - 'VpcHRJZCJ3CgRUeXBlEjwKOEVSUk9SX1BST0NFU1NJTkdfTUVTU0FHRV9DUkVBVEVEX0FDQ09V' - 'TlRfUkVRVUVTVF9JTlNURUFEEAASGAoUVU5LTk9XTl9NRVNTQUdFX1RZUEUQAhIXChNTRVNTSU' - '9OX09VVF9PRl9TWU5DEAMaVAoLR3JvdXBDcmVhdGUSGwoJc3RhdGVfa2V5GAMgASgMUghzdGF0' - 'ZUtleRIoChBncm91cF9wdWJsaWNfa2V5GAQgASgMUg5ncm91cFB1YmxpY0tleRo1CglHcm91cE' - 'pvaW4SKAoQZ3JvdXBfcHVibGljX2tleRgBIAEoDFIOZ3JvdXBQdWJsaWNLZXkaFgoUUmVzZW5k' - 'R3JvdXBQdWJsaWNLZXkayAIKC0dyb3VwVXBkYXRlEioKEWdyb3VwX2FjdGlvbl90eXBlGAEgAS' - 'gJUg9ncm91cEFjdGlvblR5cGUSMwoTYWZmZWN0ZWRfY29udGFjdF9pZBgCIAEoA0gAUhFhZmZl' - 'Y3RlZENvbnRhY3RJZIgBARIpCg5uZXdfZ3JvdXBfbmFtZRgDIAEoCUgBUgxuZXdHcm91cE5hbW' - 'WIAQESVwombmV3X2RlbGV0ZV9tZXNzYWdlc19hZnRlcl9taWxsaXNlY29uZHMYBCABKANIAlIi' - 'bmV3RGVsZXRlTWVzc2FnZXNBZnRlck1pbGxpc2Vjb25kc4gBAUIWChRfYWZmZWN0ZWRfY29udG' - 'FjdF9pZEIRCg9fbmV3X2dyb3VwX25hbWVCKQonX25ld19kZWxldGVfbWVzc2FnZXNfYWZ0ZXJf' - 'bWlsbGlzZWNvbmRzGq8BCgtUZXh0TWVzc2FnZRIqChFzZW5kZXJfbWVzc2FnZV9pZBgBIAEoCV' - 'IPc2VuZGVyTWVzc2FnZUlkEhIKBHRleHQYAiABKAlSBHRleHQSHAoJdGltZXN0YW1wGAMgASgD' - 'Ugl0aW1lc3RhbXASLQoQcXVvdGVfbWVzc2FnZV9pZBgEIAEoCUgAUg5xdW90ZU1lc3NhZ2VJZI' - 'gBAUITChFfcXVvdGVfbWVzc2FnZV9pZBrOAQoVQWRkaXRpb25hbERhdGFNZXNzYWdlEioKEXNl' - 'bmRlcl9tZXNzYWdlX2lkGAEgASgJUg9zZW5kZXJNZXNzYWdlSWQSHAoJdGltZXN0YW1wGAIgAS' - 'gDUgl0aW1lc3RhbXASEgoEdHlwZRgDIAEoCVIEdHlwZRI7ChdhZGRpdGlvbmFsX21lc3NhZ2Vf' - 'ZGF0YRgEIAEoDEgAUhVhZGRpdGlvbmFsTWVzc2FnZURhdGGIAQFCGgoYX2FkZGl0aW9uYWxfbW' - 'Vzc2FnZV9kYXRhGmQKCFJlYWN0aW9uEioKEXRhcmdldF9tZXNzYWdlX2lkGAEgASgJUg90YXJn' - 'ZXRNZXNzYWdlSWQSFAoFZW1vamkYAiABKAlSBWVtb2ppEhYKBnJlbW92ZRgDIAEoCFIGcmVtb3' - 'ZlGr4CCg1NZXNzYWdlVXBkYXRlEjgKBHR5cGUYASABKA4yJC5FbmNyeXB0ZWRDb250ZW50Lk1l' - 'c3NhZ2VVcGRhdGUuVHlwZVIEdHlwZRIvChFzZW5kZXJfbWVzc2FnZV9pZBgCIAEoCUgAUg9zZW' - '5kZXJNZXNzYWdlSWSIAQESPQobbXVsdGlwbGVfdGFyZ2V0X21lc3NhZ2VfaWRzGAMgAygJUhht' - 'dWx0aXBsZVRhcmdldE1lc3NhZ2VJZHMSFwoEdGV4dBgEIAEoCUgBUgR0ZXh0iAEBEhwKCXRpbW' - 'VzdGFtcBgFIAEoA1IJdGltZXN0YW1wIi0KBFR5cGUSCgoGREVMRVRFEAASDQoJRURJVF9URVhU' - 'EAESCgoGT1BFTkVEEAJCFAoSX3NlbmRlcl9tZXNzYWdlX2lkQgcKBV90ZXh0GoUGCgVNZWRpYR' - 'IqChFzZW5kZXJfbWVzc2FnZV9pZBgBIAEoCVIPc2VuZGVyTWVzc2FnZUlkEjAKBHR5cGUYAiAB' - 'KA4yHC5FbmNyeXB0ZWRDb250ZW50Lk1lZGlhLlR5cGVSBHR5cGUSRgodZGlzcGxheV9saW1pdF' - '9pbl9taWxsaXNlY29uZHMYAyABKANIAFIaZGlzcGxheUxpbWl0SW5NaWxsaXNlY29uZHOIAQES' - 'NwoXcmVxdWlyZXNfYXV0aGVudGljYXRpb24YBCABKAhSFnJlcXVpcmVzQXV0aGVudGljYXRpb2' - '4SHAoJdGltZXN0YW1wGAUgASgDUgl0aW1lc3RhbXASLQoQcXVvdGVfbWVzc2FnZV9pZBgGIAEo' - 'CUgBUg5xdW90ZU1lc3NhZ2VJZIgBARIqCg5kb3dubG9hZF90b2tlbhgHIAEoDEgCUg1kb3dubG' - '9hZFRva2VuiAEBEioKDmVuY3J5cHRpb25fa2V5GAggASgMSANSDWVuY3J5cHRpb25LZXmIAQES' - 'KgoOZW5jcnlwdGlvbl9tYWMYCSABKAxIBFINZW5jcnlwdGlvbk1hY4gBARIuChBlbmNyeXB0aW' - '9uX25vbmNlGAogASgMSAVSD2VuY3J5cHRpb25Ob25jZYgBARI7ChdhZGRpdGlvbmFsX21lc3Nh' - 'Z2VfZGF0YRgLIAEoDEgGUhVhZGRpdGlvbmFsTWVzc2FnZURhdGGIAQEiPgoEVHlwZRIMCghSRV' - 'VQTE9BRBAAEgkKBUlNQUdFEAESCQoFVklERU8QAhIHCgNHSUYQAxIJCgVBVURJTxAEQiAKHl9k' - 'aXNwbGF5X2xpbWl0X2luX21pbGxpc2Vjb25kc0ITChFfcXVvdGVfbWVzc2FnZV9pZEIRCg9fZG' - '93bmxvYWRfdG9rZW5CEQoPX2VuY3J5cHRpb25fa2V5QhEKD19lbmNyeXB0aW9uX21hY0ITChFf' - 'ZW5jcnlwdGlvbl9ub25jZUIaChhfYWRkaXRpb25hbF9tZXNzYWdlX2RhdGEaqQEKC01lZGlhVX' - 'BkYXRlEjYKBHR5cGUYASABKA4yIi5FbmNyeXB0ZWRDb250ZW50Lk1lZGlhVXBkYXRlLlR5cGVS' - 'BHR5cGUSKgoRdGFyZ2V0X21lc3NhZ2VfaWQYAiABKAlSD3RhcmdldE1lc3NhZ2VJZCI2CgRUeX' - 'BlEgwKCFJFT1BFTkVEEAASCgoGU1RPUkVEEAESFAoQREVDUllQVElPTl9FUlJPUhACGngKDkNv' - 'bnRhY3RSZXF1ZXN0EjkKBHR5cGUYASABKA4yJS5FbmNyeXB0ZWRDb250ZW50LkNvbnRhY3RSZX' - 'F1ZXN0LlR5cGVSBHR5cGUiKwoEVHlwZRILCgdSRVFVRVNUEAASCgoGUkVKRUNUEAESCgoGQUND' - 'RVBUEAIapAIKDUNvbnRhY3RVcGRhdGUSOAoEdHlwZRgBIAEoDjIkLkVuY3J5cHRlZENvbnRlbn' - 'QuQ29udGFjdFVwZGF0ZS5UeXBlUgR0eXBlEjcKFWF2YXRhcl9zdmdfY29tcHJlc3NlZBgCIAEo' - 'DEgAUhNhdmF0YXJTdmdDb21wcmVzc2VkiAEBEh8KCHVzZXJuYW1lGAMgASgJSAFSCHVzZXJuYW' - '1liAEBEiYKDGRpc3BsYXlfbmFtZRgEIAEoCUgCUgtkaXNwbGF5TmFtZYgBASIfCgRUeXBlEgsK' - 'B1JFUVVFU1QQABIKCgZVUERBVEUQAUIYChZfYXZhdGFyX3N2Z19jb21wcmVzc2VkQgsKCV91c2' - 'VybmFtZUIPCg1fZGlzcGxheV9uYW1lGtkBCghQdXNoS2V5cxIzCgR0eXBlGAEgASgOMh8uRW5j' - 'cnlwdGVkQ29udGVudC5QdXNoS2V5cy5UeXBlUgR0eXBlEhoKBmtleV9pZBgCIAEoA0gAUgVrZX' - 'lJZIgBARIVCgNrZXkYAyABKAxIAVIDa2V5iAEBEiIKCmNyZWF0ZWRfYXQYBCABKANIAlIJY3Jl' - 'YXRlZEF0iAEBIh8KBFR5cGUSCwoHUkVRVUVTVBAAEgoKBlVQREFURRABQgkKB19rZXlfaWRCBg' - 'oEX2tleUINCgtfY3JlYXRlZF9hdBqvAQoJRmxhbWVTeW5jEiMKDWZsYW1lX2NvdW50ZXIYASAB' - 'KANSDGZsYW1lQ291bnRlchI5ChlsYXN0X2ZsYW1lX2NvdW50ZXJfY2hhbmdlGAIgASgDUhZsYX' - 'N0RmxhbWVDb3VudGVyQ2hhbmdlEh8KC2Jlc3RfZnJpZW5kGAMgASgIUgpiZXN0RnJpZW5kEiEK' - 'DGZvcmNlX3VwZGF0ZRgEIAEoCFILZm9yY2VVcGRhdGUaTQoPVHlwaW5nSW5kaWNhdG9yEhsKCW' - 'lzX3R5cGluZxgBIAEoCFIIaXNUeXBpbmcSHQoKY3JlYXRlZF9hdBgCIAEoA1IJY3JlYXRlZEF0' - 'Gj8KFFVzZXJEaXNjb3ZlcnlSZXF1ZXN0EicKD2N1cnJlbnRfdmVyc2lvbhgBIAEoDFIOY3Vycm' - 'VudFZlcnNpb24aMQoTVXNlckRpc2NvdmVyeVVwZGF0ZRIaCghtZXNzYWdlcxgBIAMoDFIIbWVz' - 'c2FnZXNCCwoJX2dyb3VwX2lkQhEKD19pc19kaXJlY3RfY2hhdEIZChdfc2VuZGVyX3Byb2ZpbG' - 'VfY291bnRlckIgCh5fc2VuZGVyX3VzZXJfZGlzY292ZXJ5X3ZlcnNpb25CEQoPX21lc3NhZ2Vf' - 'dXBkYXRlQggKBl9tZWRpYUIPCg1fbWVkaWFfdXBkYXRlQhEKD19jb250YWN0X3VwZGF0ZUISCh' - 'BfY29udGFjdF9yZXF1ZXN0Qg0KC19mbGFtZV9zeW5jQgwKCl9wdXNoX2tleXNCCwoJX3JlYWN0' - 'aW9uQg8KDV90ZXh0X21lc3NhZ2VCDwoNX2dyb3VwX2NyZWF0ZUINCgtfZ3JvdXBfam9pbkIPCg' - '1fZ3JvdXBfdXBkYXRlQhoKGF9yZXNlbmRfZ3JvdXBfcHVibGljX2tleUIRCg9fZXJyb3JfbWVz' - 'c2FnZXNCGgoYX2FkZGl0aW9uYWxfZGF0YV9tZXNzYWdlQhMKEV90eXBpbmdfaW5kaWNhdG9yQh' - 'kKF191c2VyX2Rpc2NvdmVyeV9yZXF1ZXN0QhgKFl91c2VyX2Rpc2NvdmVyeV91cGRhdGU='); + 'dGVudC5Vc2VyRGlzY292ZXJ5VXBkYXRlSBVSE3VzZXJEaXNjb3ZlcnlVcGRhdGWIAQESYQoWa2' + 'V5X3ZlcmlmaWNhdGlvbl9wcm9vZhgYIAEoCzImLkVuY3J5cHRlZENvbnRlbnQuS2V5VmVyaWZp' + 'Y2F0aW9uUHJvb2ZIFlIUa2V5VmVyaWZpY2F0aW9uUHJvb2aIAQEa8AEKDUVycm9yTWVzc2FnZX' + 'MSOAoEdHlwZRgBIAEoDjIkLkVuY3J5cHRlZENvbnRlbnQuRXJyb3JNZXNzYWdlcy5UeXBlUgR0' + 'eXBlEiwKEnJlbGF0ZWRfcmVjZWlwdF9pZBgCIAEoCVIQcmVsYXRlZFJlY2VpcHRJZCJ3CgRUeX' + 'BlEjwKOEVSUk9SX1BST0NFU1NJTkdfTUVTU0FHRV9DUkVBVEVEX0FDQ09VTlRfUkVRVUVTVF9J' + 'TlNURUFEEAASGAoUVU5LTk9XTl9NRVNTQUdFX1RZUEUQAhIXChNTRVNTSU9OX09VVF9PRl9TWU' + '5DEAMaVAoLR3JvdXBDcmVhdGUSGwoJc3RhdGVfa2V5GAMgASgMUghzdGF0ZUtleRIoChBncm91' + 'cF9wdWJsaWNfa2V5GAQgASgMUg5ncm91cFB1YmxpY0tleRo1CglHcm91cEpvaW4SKAoQZ3JvdX' + 'BfcHVibGljX2tleRgBIAEoDFIOZ3JvdXBQdWJsaWNLZXkaFgoUUmVzZW5kR3JvdXBQdWJsaWNL' + 'ZXkayAIKC0dyb3VwVXBkYXRlEioKEWdyb3VwX2FjdGlvbl90eXBlGAEgASgJUg9ncm91cEFjdG' + 'lvblR5cGUSMwoTYWZmZWN0ZWRfY29udGFjdF9pZBgCIAEoA0gAUhFhZmZlY3RlZENvbnRhY3RJ' + 'ZIgBARIpCg5uZXdfZ3JvdXBfbmFtZRgDIAEoCUgBUgxuZXdHcm91cE5hbWWIAQESVwombmV3X2' + 'RlbGV0ZV9tZXNzYWdlc19hZnRlcl9taWxsaXNlY29uZHMYBCABKANIAlIibmV3RGVsZXRlTWVz' + 'c2FnZXNBZnRlck1pbGxpc2Vjb25kc4gBAUIWChRfYWZmZWN0ZWRfY29udGFjdF9pZEIRCg9fbm' + 'V3X2dyb3VwX25hbWVCKQonX25ld19kZWxldGVfbWVzc2FnZXNfYWZ0ZXJfbWlsbGlzZWNvbmRz' + 'Gq8BCgtUZXh0TWVzc2FnZRIqChFzZW5kZXJfbWVzc2FnZV9pZBgBIAEoCVIPc2VuZGVyTWVzc2' + 'FnZUlkEhIKBHRleHQYAiABKAlSBHRleHQSHAoJdGltZXN0YW1wGAMgASgDUgl0aW1lc3RhbXAS' + 'LQoQcXVvdGVfbWVzc2FnZV9pZBgEIAEoCUgAUg5xdW90ZU1lc3NhZ2VJZIgBAUITChFfcXVvdG' + 'VfbWVzc2FnZV9pZBrOAQoVQWRkaXRpb25hbERhdGFNZXNzYWdlEioKEXNlbmRlcl9tZXNzYWdl' + 'X2lkGAEgASgJUg9zZW5kZXJNZXNzYWdlSWQSHAoJdGltZXN0YW1wGAIgASgDUgl0aW1lc3RhbX' + 'ASEgoEdHlwZRgDIAEoCVIEdHlwZRI7ChdhZGRpdGlvbmFsX21lc3NhZ2VfZGF0YRgEIAEoDEgA' + 'UhVhZGRpdGlvbmFsTWVzc2FnZURhdGGIAQFCGgoYX2FkZGl0aW9uYWxfbWVzc2FnZV9kYXRhGm' + 'QKCFJlYWN0aW9uEioKEXRhcmdldF9tZXNzYWdlX2lkGAEgASgJUg90YXJnZXRNZXNzYWdlSWQS' + 'FAoFZW1vamkYAiABKAlSBWVtb2ppEhYKBnJlbW92ZRgDIAEoCFIGcmVtb3ZlGr4CCg1NZXNzYW' + 'dlVXBkYXRlEjgKBHR5cGUYASABKA4yJC5FbmNyeXB0ZWRDb250ZW50Lk1lc3NhZ2VVcGRhdGUu' + 'VHlwZVIEdHlwZRIvChFzZW5kZXJfbWVzc2FnZV9pZBgCIAEoCUgAUg9zZW5kZXJNZXNzYWdlSW' + 'SIAQESPQobbXVsdGlwbGVfdGFyZ2V0X21lc3NhZ2VfaWRzGAMgAygJUhhtdWx0aXBsZVRhcmdl' + 'dE1lc3NhZ2VJZHMSFwoEdGV4dBgEIAEoCUgBUgR0ZXh0iAEBEhwKCXRpbWVzdGFtcBgFIAEoA1' + 'IJdGltZXN0YW1wIi0KBFR5cGUSCgoGREVMRVRFEAASDQoJRURJVF9URVhUEAESCgoGT1BFTkVE' + 'EAJCFAoSX3NlbmRlcl9tZXNzYWdlX2lkQgcKBV90ZXh0GoUGCgVNZWRpYRIqChFzZW5kZXJfbW' + 'Vzc2FnZV9pZBgBIAEoCVIPc2VuZGVyTWVzc2FnZUlkEjAKBHR5cGUYAiABKA4yHC5FbmNyeXB0' + 'ZWRDb250ZW50Lk1lZGlhLlR5cGVSBHR5cGUSRgodZGlzcGxheV9saW1pdF9pbl9taWxsaXNlY2' + '9uZHMYAyABKANIAFIaZGlzcGxheUxpbWl0SW5NaWxsaXNlY29uZHOIAQESNwoXcmVxdWlyZXNf' + 'YXV0aGVudGljYXRpb24YBCABKAhSFnJlcXVpcmVzQXV0aGVudGljYXRpb24SHAoJdGltZXN0YW' + '1wGAUgASgDUgl0aW1lc3RhbXASLQoQcXVvdGVfbWVzc2FnZV9pZBgGIAEoCUgBUg5xdW90ZU1l' + 'c3NhZ2VJZIgBARIqCg5kb3dubG9hZF90b2tlbhgHIAEoDEgCUg1kb3dubG9hZFRva2VuiAEBEi' + 'oKDmVuY3J5cHRpb25fa2V5GAggASgMSANSDWVuY3J5cHRpb25LZXmIAQESKgoOZW5jcnlwdGlv' + 'bl9tYWMYCSABKAxIBFINZW5jcnlwdGlvbk1hY4gBARIuChBlbmNyeXB0aW9uX25vbmNlGAogAS' + 'gMSAVSD2VuY3J5cHRpb25Ob25jZYgBARI7ChdhZGRpdGlvbmFsX21lc3NhZ2VfZGF0YRgLIAEo' + 'DEgGUhVhZGRpdGlvbmFsTWVzc2FnZURhdGGIAQEiPgoEVHlwZRIMCghSRVVQTE9BRBAAEgkKBU' + 'lNQUdFEAESCQoFVklERU8QAhIHCgNHSUYQAxIJCgVBVURJTxAEQiAKHl9kaXNwbGF5X2xpbWl0' + 'X2luX21pbGxpc2Vjb25kc0ITChFfcXVvdGVfbWVzc2FnZV9pZEIRCg9fZG93bmxvYWRfdG9rZW' + '5CEQoPX2VuY3J5cHRpb25fa2V5QhEKD19lbmNyeXB0aW9uX21hY0ITChFfZW5jcnlwdGlvbl9u' + 'b25jZUIaChhfYWRkaXRpb25hbF9tZXNzYWdlX2RhdGEaqQEKC01lZGlhVXBkYXRlEjYKBHR5cG' + 'UYASABKA4yIi5FbmNyeXB0ZWRDb250ZW50Lk1lZGlhVXBkYXRlLlR5cGVSBHR5cGUSKgoRdGFy' + 'Z2V0X21lc3NhZ2VfaWQYAiABKAlSD3RhcmdldE1lc3NhZ2VJZCI2CgRUeXBlEgwKCFJFT1BFTk' + 'VEEAASCgoGU1RPUkVEEAESFAoQREVDUllQVElPTl9FUlJPUhACGngKDkNvbnRhY3RSZXF1ZXN0' + 'EjkKBHR5cGUYASABKA4yJS5FbmNyeXB0ZWRDb250ZW50LkNvbnRhY3RSZXF1ZXN0LlR5cGVSBH' + 'R5cGUiKwoEVHlwZRILCgdSRVFVRVNUEAASCgoGUkVKRUNUEAESCgoGQUNDRVBUEAIapAIKDUNv' + 'bnRhY3RVcGRhdGUSOAoEdHlwZRgBIAEoDjIkLkVuY3J5cHRlZENvbnRlbnQuQ29udGFjdFVwZG' + 'F0ZS5UeXBlUgR0eXBlEjcKFWF2YXRhcl9zdmdfY29tcHJlc3NlZBgCIAEoDEgAUhNhdmF0YXJT' + 'dmdDb21wcmVzc2VkiAEBEh8KCHVzZXJuYW1lGAMgASgJSAFSCHVzZXJuYW1liAEBEiYKDGRpc3' + 'BsYXlfbmFtZRgEIAEoCUgCUgtkaXNwbGF5TmFtZYgBASIfCgRUeXBlEgsKB1JFUVVFU1QQABIK' + 'CgZVUERBVEUQAUIYChZfYXZhdGFyX3N2Z19jb21wcmVzc2VkQgsKCV91c2VybmFtZUIPCg1fZG' + 'lzcGxheV9uYW1lGtkBCghQdXNoS2V5cxIzCgR0eXBlGAEgASgOMh8uRW5jcnlwdGVkQ29udGVu' + 'dC5QdXNoS2V5cy5UeXBlUgR0eXBlEhoKBmtleV9pZBgCIAEoA0gAUgVrZXlJZIgBARIVCgNrZX' + 'kYAyABKAxIAVIDa2V5iAEBEiIKCmNyZWF0ZWRfYXQYBCABKANIAlIJY3JlYXRlZEF0iAEBIh8K' + 'BFR5cGUSCwoHUkVRVUVTVBAAEgoKBlVQREFURRABQgkKB19rZXlfaWRCBgoEX2tleUINCgtfY3' + 'JlYXRlZF9hdBqvAQoJRmxhbWVTeW5jEiMKDWZsYW1lX2NvdW50ZXIYASABKANSDGZsYW1lQ291' + 'bnRlchI5ChlsYXN0X2ZsYW1lX2NvdW50ZXJfY2hhbmdlGAIgASgDUhZsYXN0RmxhbWVDb3VudG' + 'VyQ2hhbmdlEh8KC2Jlc3RfZnJpZW5kGAMgASgIUgpiZXN0RnJpZW5kEiEKDGZvcmNlX3VwZGF0' + 'ZRgEIAEoCFILZm9yY2VVcGRhdGUaTQoPVHlwaW5nSW5kaWNhdG9yEhsKCWlzX3R5cGluZxgBIA' + 'EoCFIIaXNUeXBpbmcSHQoKY3JlYXRlZF9hdBgCIAEoA1IJY3JlYXRlZEF0Gj8KFFVzZXJEaXNj' + 'b3ZlcnlSZXF1ZXN0EicKD2N1cnJlbnRfdmVyc2lvbhgBIAEoDFIOY3VycmVudFZlcnNpb24aMQ' + 'oTVXNlckRpc2NvdmVyeVVwZGF0ZRIaCghtZXNzYWdlcxgBIAMoDFIIbWVzc2FnZXMaPQoUS2V5' + 'VmVyaWZpY2F0aW9uUHJvb2YSJQoOY2FsY3VsYXRlZF9tYWMYASABKAxSDWNhbGN1bGF0ZWRNYW' + 'NCCwoJX2dyb3VwX2lkQhEKD19pc19kaXJlY3RfY2hhdEIZChdfc2VuZGVyX3Byb2ZpbGVfY291' + 'bnRlckIgCh5fc2VuZGVyX3VzZXJfZGlzY292ZXJ5X3ZlcnNpb25CEQoPX21lc3NhZ2VfdXBkYX' + 'RlQggKBl9tZWRpYUIPCg1fbWVkaWFfdXBkYXRlQhEKD19jb250YWN0X3VwZGF0ZUISChBfY29u' + 'dGFjdF9yZXF1ZXN0Qg0KC19mbGFtZV9zeW5jQgwKCl9wdXNoX2tleXNCCwoJX3JlYWN0aW9uQg' + '8KDV90ZXh0X21lc3NhZ2VCDwoNX2dyb3VwX2NyZWF0ZUINCgtfZ3JvdXBfam9pbkIPCg1fZ3Jv' + 'dXBfdXBkYXRlQhoKGF9yZXNlbmRfZ3JvdXBfcHVibGljX2tleUIRCg9fZXJyb3JfbWVzc2FnZX' + 'NCGgoYX2FkZGl0aW9uYWxfZGF0YV9tZXNzYWdlQhMKEV90eXBpbmdfaW5kaWNhdG9yQhkKF191' + 'c2VyX2Rpc2NvdmVyeV9yZXF1ZXN0QhgKFl91c2VyX2Rpc2NvdmVyeV91cGRhdGVCGQoXX2tleV' + '92ZXJpZmljYXRpb25fcHJvb2Y='); diff --git a/lib/src/model/protobuf/client/generated/qr.pb.dart b/lib/src/model/protobuf/client/generated/qr.pb.dart index 284eecd1..0e7408b8 100644 --- a/lib/src/model/protobuf/client/generated/qr.pb.dart +++ b/lib/src/model/protobuf/client/generated/qr.pb.dart @@ -99,6 +99,7 @@ class PublicProfile extends $pb.GeneratedMessage { $fixnum.Int64? registrationId, $core.List<$core.int>? signedPrekeySignature, $fixnum.Int64? signedPrekeyId, + $core.List<$core.int>? secretVerificationToken, }) { final result = create(); if (userId != null) result.userId = userId; @@ -109,6 +110,8 @@ class PublicProfile extends $pb.GeneratedMessage { if (signedPrekeySignature != null) result.signedPrekeySignature = signedPrekeySignature; if (signedPrekeyId != null) result.signedPrekeyId = signedPrekeyId; + if (secretVerificationToken != null) + result.secretVerificationToken = secretVerificationToken; return result; } @@ -134,6 +137,8 @@ class PublicProfile extends $pb.GeneratedMessage { ..a<$core.List<$core.int>>( 6, _omitFieldNames ? '' : 'signedPrekeySignature', $pb.PbFieldType.OY) ..aInt64(7, _omitFieldNames ? '' : 'signedPrekeyId') + ..a<$core.List<$core.int>>( + 8, _omitFieldNames ? '' : 'secretVerificationToken', $pb.PbFieldType.OY) ..hasRequiredFields = false; @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @@ -220,6 +225,16 @@ class PublicProfile extends $pb.GeneratedMessage { $core.bool hasSignedPrekeyId() => $_has(6); @$pb.TagNumber(7) void clearSignedPrekeyId() => $_clearField(7); + + @$pb.TagNumber(8) + $core.List<$core.int> get secretVerificationToken => $_getN(7); + @$pb.TagNumber(8) + set secretVerificationToken($core.List<$core.int> value) => + $_setBytes(7, value); + @$pb.TagNumber(8) + $core.bool hasSecretVerificationToken() => $_has(7); + @$pb.TagNumber(8) + void clearSecretVerificationToken() => $_clearField(8); } const $core.bool _omitFieldNames = diff --git a/lib/src/model/protobuf/client/generated/qr.pbjson.dart b/lib/src/model/protobuf/client/generated/qr.pbjson.dart index 2d9d1da5..74d21142 100644 --- a/lib/src/model/protobuf/client/generated/qr.pbjson.dart +++ b/lib/src/model/protobuf/client/generated/qr.pbjson.dart @@ -67,6 +67,18 @@ const PublicProfile$json = { '10': 'signedPrekeySignature' }, {'1': 'signed_prekey_id', '3': 7, '4': 1, '5': 3, '10': 'signedPrekeyId'}, + { + '1': 'secret_verification_token', + '3': 8, + '4': 1, + '5': 12, + '9': 0, + '10': 'secretVerificationToken', + '17': true + }, + ], + '8': [ + {'1': '_secret_verification_token'}, ], }; @@ -77,4 +89,5 @@ final $typed_data.Uint8List publicProfileDescriptor = $convert.base64Decode( 'dHlLZXkSIwoNc2lnbmVkX3ByZWtleRgEIAEoDFIMc2lnbmVkUHJla2V5EicKD3JlZ2lzdHJhdG' 'lvbl9pZBgFIAEoA1IOcmVnaXN0cmF0aW9uSWQSNgoXc2lnbmVkX3ByZWtleV9zaWduYXR1cmUY' 'BiABKAxSFXNpZ25lZFByZWtleVNpZ25hdHVyZRIoChBzaWduZWRfcHJla2V5X2lkGAcgASgDUg' - '5zaWduZWRQcmVrZXlJZA=='); + '5zaWduZWRQcmVrZXlJZBI/ChlzZWNyZXRfdmVyaWZpY2F0aW9uX3Rva2VuGAggASgMSABSF3Nl' + 'Y3JldFZlcmlmaWNhdGlvblRva2VuiAEBQhwKGl9zZWNyZXRfdmVyaWZpY2F0aW9uX3Rva2Vu'); diff --git a/lib/src/model/protobuf/client/messages.proto b/lib/src/model/protobuf/client/messages.proto index c3303c5a..a2093ca0 100644 --- a/lib/src/model/protobuf/client/messages.proto +++ b/lib/src/model/protobuf/client/messages.proto @@ -56,6 +56,7 @@ message EncryptedContent { optional TypingIndicator typing_indicator = 20; optional UserDiscoveryRequest user_discovery_request = 22; optional UserDiscoveryUpdate user_discovery_update = 23; + optional KeyVerificationProof key_verification_proof = 24; message ErrorMessages { enum Type { @@ -209,4 +210,8 @@ message EncryptedContent { repeated bytes messages = 1; } + message KeyVerificationProof { + bytes calculated_mac = 1; + } + } \ No newline at end of file diff --git a/lib/src/model/protobuf/client/qr.proto b/lib/src/model/protobuf/client/qr.proto index 3024c87b..7193a6ac 100644 --- a/lib/src/model/protobuf/client/qr.proto +++ b/lib/src/model/protobuf/client/qr.proto @@ -16,4 +16,5 @@ message PublicProfile { int64 registration_id = 5; bytes signed_prekey_signature = 6; int64 signed_prekey_id = 7; + optional bytes secret_verification_token = 8; } diff --git a/lib/src/services/api/server_messages.api.dart b/lib/src/services/api/server_messages.api.dart index 4e7b92b3..46dfd569 100644 --- a/lib/src/services/api/server_messages.api.dart +++ b/lib/src/services/api/server_messages.api.dart @@ -29,6 +29,7 @@ import 'package:twonly/src/services/api/client2client/text_message.c2c.dart'; import 'package:twonly/src/services/api/client2client/user_discovery.c2c.dart'; import 'package:twonly/src/services/api/messages.api.dart'; import 'package:twonly/src/services/group.services.dart'; +import 'package:twonly/src/services/key_verification.service.dart'; import 'package:twonly/src/services/notifications/background.notifications.dart'; import 'package:twonly/src/services/signal/encryption.signal.dart'; import 'package:twonly/src/services/signal/session.signal.dart'; @@ -330,6 +331,14 @@ Future<(EncryptedContent?, PlaintextContent?)> handleEncryptedMessage( return (null, null); } + if (content.hasKeyVerificationProof()) { + await KeyVerificationService.handleVerificationProof( + fromUserId, + content.keyVerificationProof.calculatedMac, + ); + return (null, null); + } + if (content.hasMediaUpdate()) { await handleMediaUpdate( fromUserId, diff --git a/lib/src/services/intent/links.intent.dart b/lib/src/services/intent/links.intent.dart index 42a50f0f..5c1360b4 100644 --- a/lib/src/services/intent/links.intent.dart +++ b/lib/src/services/intent/links.intent.dart @@ -4,15 +4,14 @@ import 'dart:io'; import 'dart:typed_data'; import 'package:collection/collection.dart'; -import 'package:drift/drift.dart' show Value; import 'package:flutter/material.dart'; import 'package:flutter_sharing_intent/flutter_sharing_intent.dart'; import 'package:flutter_sharing_intent/model/sharing_file.dart'; import 'package:go_router/go_router.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/constants/routes.keys.dart'; +import 'package:twonly/src/database/tables/contacts.table.dart'; import 'package:twonly/src/database/tables/mediafiles.table.dart'; -import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/services/api/mediafiles/upload.api.dart'; import 'package:twonly/src/services/signal/session.signal.dart'; import 'package:twonly/src/utils/log.dart'; @@ -70,22 +69,19 @@ Future handleIntentUrl(BuildContext context, Uri uri) async { return true; } if (storedPublicKey.equals(receivedPublicKey)) { - if (!contact.verified) { - final markAsVerified = await showAlertDialog( - context, - context.lang.linkFromUsername(contact.username), - context.lang.linkFromUsernameLong, - customOk: context.lang.gotLinkFromFriend, + final markAsVerified = await showAlertDialog( + context, + context.lang.linkFromUsername(contact.username), + context.lang.linkFromUsernameLong, + customOk: context.lang.gotLinkFromFriend, + ); + if (markAsVerified) { + await twonlyDB.keyVerificationDao.addKeyVerification( + contact.userId, + VerificationType.link, ); - if (markAsVerified) { - await twonlyDB.contactsDao.updateContact( - contact.userId, - const ContactsCompanion( - verified: Value(true), - ), - ); - } - } else { + } + if (context.mounted) { await context.push(Routes.profileContact(contact.userId)); } } else { diff --git a/lib/src/services/key_verification.service.dart b/lib/src/services/key_verification.service.dart new file mode 100644 index 00000000..410e1b58 --- /dev/null +++ b/lib/src/services/key_verification.service.dart @@ -0,0 +1,120 @@ +import 'dart:typed_data'; + +import 'package:collection/collection.dart'; +import 'package:cryptography_plus/cryptography_plus.dart'; +import 'package:twonly/locator.dart'; +import 'package:twonly/src/database/tables/contacts.table.dart'; +import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart' + as pb; +import 'package:twonly/src/services/api/messages.api.dart'; +import 'package:twonly/src/services/signal/identity.signal.dart'; +import 'package:twonly/src/services/signal/session.signal.dart'; +import 'package:twonly/src/utils/log.dart'; +import 'package:twonly/src/utils/misc.dart'; + +class KeyVerificationService { + static Future> getNewSecretVerificationToken() async { + final token = getRandomUint8List(16); + await twonlyDB.keyVerificationDao.insertVerificationToken(token); + return token.toList(); + } + + static Future handleScannedVerificationToken( + int contactId, + Uint8List contactPubKey, + List secretToken, + ) async { + Log.info('Notifying verified user using the secret token'); + + final calculatedMac = await _createVerificationBytes( + contactId, + contactPubKey, + secretToken, + false, + ); + + await sendCipherText( + contactId, + pb.EncryptedContent( + keyVerificationProof: pb.EncryptedContent_KeyVerificationProof( + calculatedMac: calculatedMac, + ), + ), + ); + } + + static Future handleVerificationProof( + int fromUserId, + List receivedMac, + ) async { + Log.info('Received a verification proof. Verifying the calculated mac...'); + + final contactPubKey = await getPublicKeyFromContact(fromUserId); + if (contactPubKey == null) { + Log.error('No public key stored..'); + return; + } + + final secretTokens = await twonlyDB.keyVerificationDao + .getRecentVerificationTokens(); + for (final secretToken in secretTokens) { + final recalculatedMac = await _createVerificationBytes( + fromUserId, + contactPubKey, + secretToken.token, + true, + ); + if (recalculatedMac.equals(receivedMac)) { + await twonlyDB.keyVerificationDao.addKeyVerification( + fromUserId, + VerificationType.secretQrToken, + ); + Log.info('Contact was verified via secretQrToken'); + return; + } + } + + Log.error('No valid secret token could be found...'); + } +} + +Future> _createVerificationBytes( + int contactId, + Uint8List contactPubKey, + List secretToken, + bool verifying, +) async { + final bytes = []; + + final userPublicKey = await getUserPublicKey(); + + final ownBytes = [ + ..._userIdToLeBytes(userService.currentUser.userId), + ...userPublicKey, + ]; + + final contactBytes = [..._userIdToLeBytes(contactId), ...contactPubKey]; + + if (verifying) { + bytes + ..addAll(ownBytes) + ..addAll(contactBytes); + } else { + bytes + ..addAll(contactBytes) + ..addAll(ownBytes); + } + + final hmac = Hmac.sha256(); + final mac = await hmac.calculateMac( + Uint8List.fromList(bytes), + secretKey: SecretKey(secretToken), + ); + + return mac.bytes; +} + +List _userIdToLeBytes(int userId) { + final byteData = ByteData(8)..setInt64(0, userId, Endian.little); + return byteData.buffer.asUint8List(); +} diff --git a/lib/src/services/signal/identity.signal.dart b/lib/src/services/signal/identity.signal.dart index 693a23c7..a257e0fc 100644 --- a/lib/src/services/signal/identity.signal.dart +++ b/lib/src/services/signal/identity.signal.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:typed_data'; import 'package:clock/clock.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; @@ -91,6 +92,12 @@ Future getSignalIdentity() async { } } +Future getUserPublicKey() async { + final signalIdentity = (await getSignalIdentity())!; + final signalStore = await getSignalStoreFromIdentity(signalIdentity); + return (await signalStore.getIdentityKeyPair()).getPublicKey().serialize(); +} + Future createIfNotExistsSignalIdentity() async { const storage = FlutterSecureStorage(); diff --git a/lib/src/services/user_discovery.service.dart b/lib/src/services/user_discovery.service.dart index 5a17881f..2ae8cb87 100644 --- a/lib/src/services/user_discovery.service.dart +++ b/lib/src/services/user_discovery.service.dart @@ -1,5 +1,4 @@ import 'dart:convert'; - import 'package:collection/collection.dart'; import 'package:drift/drift.dart'; import 'package:flutter/foundation.dart'; @@ -7,9 +6,9 @@ import 'package:twonly/core/bridge/wrapper/user_discovery.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/protobuf/client/generated/user_discovery/types.pb.dart'; +import 'package:twonly/src/services/signal/identity.signal.dart'; import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/utils/log.dart'; -import 'package:twonly/src/utils/qr.dart'; class UserDiscoveryService { static Future checkForNewAnnouncedUsers() async { diff --git a/lib/src/services/user_study.service.dart b/lib/src/services/user_study.service.dart index 15deffab..7be7a341 100644 --- a/lib/src/services/user_study.service.dart +++ b/lib/src/services/user_study.service.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:twonly/locator.dart'; +import 'package:twonly/src/database/tables/contacts.table.dart'; import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/utils/keyvalue.dart'; import 'package:twonly/src/utils/log.dart'; @@ -43,11 +44,28 @@ Future handleUserStudyUpload() async { } final contacts = await twonlyDB.contactsDao.getAllContacts(); + final verifications = await twonlyDB.keyVerificationDao + .getFirstVerificationTypeByContacts(); final dataCollection = { 'total_contacts': contacts.length, 'accepted_contacts': contacts.where((c) => c.accepted).length, - 'verified_contacts': contacts.where((c) => c.verified).length, + 'verified_contacts': verifications.length, + 'verified_contacts_via_migrated_from_old_version': verifications.values + .where((c) => c == VerificationType.migratedFromOldVersion) + .length, + 'verified_contacts_via_qr_scanned': verifications.values + .where((c) => c == VerificationType.qrScanned) + .length, + 'verified_contacts_via_link': verifications.values + .where((c) => c == VerificationType.link) + .length, + 'verified_contacts_via_secret_qr_token': verifications.values + .where((c) => c == VerificationType.secretQrToken) + .length, + 'verified_contacts_via_contact_shared_by_verified': verifications.values + .where((c) => c == VerificationType.contactSharedByVerified) + .length, }; final response = await http.post( diff --git a/lib/src/utils/qr.dart b/lib/src/utils/qr.dart index 9650207c..5e95ac82 100644 --- a/lib/src/utils/qr.dart +++ b/lib/src/utils/qr.dart @@ -1,59 +1,114 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:collection/collection.dart' show ListExtensions; import 'package:drift/drift.dart' show Value; import 'package:fixnum/fixnum.dart'; import 'package:flutter/foundation.dart'; import 'package:twonly/locator.dart'; +import 'package:twonly/src/database/tables/contacts.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pb.dart'; import 'package:twonly/src/model/protobuf/client/generated/qr.pb.dart'; import 'package:twonly/src/services/api/utils.api.dart'; +import 'package:twonly/src/services/key_verification.service.dart'; import 'package:twonly/src/services/signal/identity.signal.dart'; +import 'package:twonly/src/services/signal/session.signal.dart'; import 'package:twonly/src/services/signal/utils.signal.dart'; +import 'package:twonly/src/utils/log.dart'; -Future getProfileQrCodeData() async { - final signalIdentity = (await getSignalIdentity())!; +class QrCodeUtils { + static String linkPrefix = 'https://me.twonly.eu/qr/#'; - final signalStore = await getSignalStoreFromIdentity(signalIdentity); + static Future publicProfileLink() async { + final signalIdentity = (await getSignalIdentity())!; - final signedPreKey = (await signalStore.loadSignedPreKeys())[0]; + final signalStore = await getSignalStoreFromIdentity(signalIdentity); - final publicProfile = PublicProfile( - userId: Int64(userService.currentUser.userId), - username: userService.currentUser.username, - publicIdentityKey: (await signalStore.getIdentityKeyPair()) - .getPublicKey() - .serialize(), - registrationId: Int64(signalIdentity.registrationId), - signedPrekey: signedPreKey.getKeyPair().publicKey.serialize(), - signedPrekeySignature: signedPreKey.signature, - signedPrekeyId: Int64(signedPreKey.id), - ); + final signedPreKey = (await signalStore.loadSignedPreKeys())[0]; - final data = publicProfile.writeToBuffer(); + final secretVerificationToken = + await KeyVerificationService.getNewSecretVerificationToken(); - final qrEnvelope = QREnvelope( - type: QREnvelope_Type.PUBLIC_PROFILE, - data: data, - ); + final publicProfile = PublicProfile( + userId: Int64(userService.currentUser.userId), + username: userService.currentUser.username, + publicIdentityKey: (await signalStore.getIdentityKeyPair()) + .getPublicKey() + .serialize(), + registrationId: Int64(signalIdentity.registrationId), + signedPrekey: signedPreKey.getKeyPair().publicKey.serialize(), + signedPrekeySignature: signedPreKey.signature, + signedPrekeyId: Int64(signedPreKey.id), + secretVerificationToken: secretVerificationToken, + ); - return qrEnvelope.writeToBuffer(); -} + final data = publicProfile.writeToBuffer(); -Future getUserPublicKey() async { - final signalIdentity = (await getSignalIdentity())!; - final signalStore = await getSignalStoreFromIdentity(signalIdentity); - return (await signalStore.getIdentityKeyPair()).getPublicKey().serialize(); -} + final qrEnvelope = QREnvelope( + type: QREnvelope_Type.PUBLIC_PROFILE, + data: data, + ); -PublicProfile? parseQrCodeData(Uint8List rawBytes) { - try { - final envelop = QREnvelope.fromBuffer(rawBytes); - if (envelop.type == QREnvelope_Type.PUBLIC_PROFILE) { - return PublicProfile.fromBuffer(envelop.data); - } - } catch (e) { - // Log.warn(e); + final bytes = qrEnvelope.writeToBuffer(); + final urlSafeBase64 = base64Url.encode(bytes); + + return '$linkPrefix$urlSafeBase64'; + } + + // returns: profile, NEW_USER=true/VERIFIED_USER=false, VERIFICATION_OK + static Future<(PublicProfile, Contact?, bool)?> handleQrCodeLink( + String link, + ) async { + late PublicProfile profile; + + try { + final bytes = base64Url.decode(link.replaceFirst(linkPrefix, '')); + final envelope = QREnvelope.fromBuffer(bytes); + if (envelope.type != QREnvelope_Type.PUBLIC_PROFILE) return null; + profile = PublicProfile.fromBuffer(envelope.data); + } catch (e) { + Log.error(e); + return null; + } + + final contact = await twonlyDB.contactsDao.getContactById( + profile.userId.toInt(), + ); + + if (contact == null || !contact.accepted) { + if (profile.username == userService.currentUser.username) { + return null; + } + // NEW_USER + return (profile, null, false); + } + + final storedPublicKey = await getPublicKeyFromContact(contact.userId); + if (storedPublicKey == null) return null; + + final verificationOk = profile.publicIdentityKey.equals( + storedPublicKey.toList(), + ); + + if (verificationOk) { + if (profile.hasSecretVerificationToken()) { + unawaited( + KeyVerificationService.handleScannedVerificationToken( + contact.userId, + storedPublicKey, + profile.secretVerificationToken, + ), + ); + } + await twonlyDB.keyVerificationDao.addKeyVerification( + contact.userId, + VerificationType.qrScanned, + ); + } + + return (profile, contact, verificationOk); } - return null; } Future addNewContactFromPublicProfile(PublicProfile profile) async { @@ -72,14 +127,28 @@ Future addNewContactFromPublicProfile(PublicProfile profile) async { requested: const Value(false), blocked: const Value(false), deletedByUser: const Value(false), - verified: const Value( - true, - ), // This contact was added from a QR-Code scan, so the public key was not loaded from the server ), ); + // The user was added via the profile scanned from the QR code so the scanned public key was used. + await twonlyDB.keyVerificationDao.addKeyVerification( + profile.userId.toInt(), + VerificationType.qrScanned, + ); + if (added > 0) { - return importSignalContactAndCreateRequest(userdata); + if (await importSignalContactAndCreateRequest(userdata)) { + if (profile.hasSecretVerificationToken()) { + await KeyVerificationService.handleScannedVerificationToken( + profile.userId.toInt(), + Uint8List.fromList(profile.publicIdentityKey), + profile.secretVerificationToken, + ); + } + return true; + } + return false; } + return false; } diff --git a/lib/src/visual/components/verification_badge.comp.dart b/lib/src/visual/components/verification_badge.comp.dart index 847ca967..28eef92b 100644 --- a/lib/src/visual/components/verification_badge.comp.dart +++ b/lib/src/visual/components/verification_badge.comp.dart @@ -28,27 +28,29 @@ class VerificationBadgeComp extends StatefulWidget { } class _VerificationBadgeCompState extends State { - bool isVerified = false; - Contact? contact; + bool _isVerified = false; - StreamSubscription>? stream; + StreamSubscription? _streamAllVerified; + StreamSubscription>? _streamContactVerification; @override void initState() { if (widget.group != null) { - stream = twonlyDB.groupsDao - .watchGroupContact(widget.group!.groupId) - .listen((contacts) { - if (contacts.length == 1) { - contact = contacts.first; - } + _streamAllVerified = twonlyDB.keyVerificationDao + .watchAllGroupMembersVerified(widget.group!.groupId) + .listen((update) { setState(() { - isVerified = contacts.every((t) => t.verified); + _isVerified = update; }); }); } else if (widget.contact != null) { - isVerified = widget.contact!.verified; - contact = widget.contact; + _streamContactVerification = twonlyDB.keyVerificationDao + .watchContactVerification(widget.contact!.userId) + .listen((update) { + setState(() { + _isVerified = update.isNotEmpty; + }); + }); } super.initState(); @@ -56,15 +58,16 @@ class _VerificationBadgeCompState extends State { @override void dispose() { - stream?.cancel(); + _streamAllVerified?.cancel(); + _streamContactVerification?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { - if (!isVerified && widget.showOnlyIfVerified) return Container(); + if (!_isVerified && widget.showOnlyIfVerified) return Container(); return GestureDetector( - onTap: (contact == null || !widget.clickable) + onTap: (!widget.clickable) ? null : () => context.push(Routes.settingsHelpFaqVerifyBadge), child: ColoredBox( @@ -77,7 +80,7 @@ class _VerificationBadgeCompState extends State { bottom: 3, ), child: SvgIcon( - assetPath: isVerified + assetPath: _isVerified ? SvgIcons.verifiedGreen : SvgIcons.verifiedRed, size: widget.size, diff --git a/lib/src/visual/views/camera/camera_preview_components/main_camera_controller.dart b/lib/src/visual/views/camera/camera_preview_components/main_camera_controller.dart index f7bd197e..2fac41c4 100644 --- a/lib/src/visual/views/camera/camera_preview_components/main_camera_controller.dart +++ b/lib/src/visual/views/camera/camera_preview_components/main_camera_controller.dart @@ -3,8 +3,6 @@ import 'dart:io'; import 'package:camera/camera.dart'; import 'package:clock/clock.dart'; -import 'package:collection/collection.dart'; -import 'package:drift/drift.dart' show Value; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -16,7 +14,6 @@ import 'package:twonly/locator.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/protobuf/client/generated/qr.pb.dart'; -import 'package:twonly/src/services/signal/session.signal.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/qr.dart'; @@ -48,6 +45,7 @@ class MainCameraController { bool initCameraStarted = true; Map contactsVerified = {}; Map scannedNewProfiles = {}; + Set _handledProfileLinks = {}; String? scannedUrl; GlobalKey zoomButtonKey = GlobalKey(); GlobalKey cameraPreviewKey = GlobalKey(); @@ -340,70 +338,56 @@ class MainCameraController { } for (final barcode in barcodes) { - if (barcode.displayValue != null) { - if (barcode.displayValue!.startsWith('http://') || - barcode.displayValue!.startsWith('https://')) { - scannedUrl = barcode.displayValue; - if (sharedLinkForPreview == null) { - timeSharedLinkWasSetWithQr = clock.now(); - setSharedLinkForPreview(Uri.parse(scannedUrl!)); - } - } - } - if (barcode.rawBytes == null) continue; + if (barcode.displayValue == null) continue; + final link = barcode.displayValue!; - final profile = parseQrCodeData(barcode.rawBytes!); + if (link.startsWith(QrCodeUtils.linkPrefix)) { + if (_handledProfileLinks.contains(link)) continue; + _handledProfileLinks.add(link); - if (profile == null) continue; + final res = await QrCodeUtils.handleQrCodeLink(link); + if (res == null) continue; + final (profile, contact, verificationOk) = res; - final contact = await twonlyDB.contactsDao.getContactById( - profile.userId.toInt(), - ); - - if (contact != null && contact.accepted) { - if (contactsVerified[contact.userId] == null) { - final storedPublicKey = await getPublicKeyFromContact( - contact.userId, - ); - if (storedPublicKey != null) { - final verificationOk = profile.publicIdentityKey.equals( - storedPublicKey.toList(), - ); - contactsVerified[contact.userId] = ScannedVerifiedContact( - contact: contact, - verificationOk: verificationOk, - ); - if (verificationOk) { - await twonlyDB.contactsDao.updateContact( - contact.userId, - const ContactsCompanion(verified: Value(true)), - ); - } - await HapticFeedback.heavyImpact(); - if (verificationOk) { - AppGlobalKeys.scaffoldMessengerKey.currentState?.showSnackBar( - SnackBar( - content: Text( - AppGlobalKeys.scaffoldMessengerKey.currentContext?.lang - .verifiedPublicKey( - getContactDisplayName(contact), - ) ?? - '', - ), - duration: const Duration(seconds: 6), - ), - ); - } - } - } - } else { - if (profile.username != userService.currentUser.username) { + if (contact == null) { if (scannedNewProfiles[profile.userId.toInt()] == null) { await HapticFeedback.heavyImpact(); scannedNewProfiles[profile.userId.toInt()] = ScannedNewProfile( profile: profile, ); } + continue; + } + + if (contactsVerified[contact.userId] == null) { + contactsVerified[contact.userId] = ScannedVerifiedContact( + contact: contact, + verificationOk: verificationOk, + ); + + await HapticFeedback.heavyImpact(); + if (verificationOk) { + AppGlobalKeys.scaffoldMessengerKey.currentState?.showSnackBar( + SnackBar( + content: Text( + AppGlobalKeys.scaffoldMessengerKey.currentContext?.lang + .verifiedPublicKey( + getContactDisplayName(contact), + ) ?? + '', + ), + duration: const Duration(seconds: 6), + ), + ); + } + } + } + + if (link.startsWith('http://') || link.startsWith('https://')) { + scannedUrl = link; + if (sharedLinkForPreview == null) { + timeSharedLinkWasSetWithQr = clock.now(); + setSharedLinkForPreview(Uri.parse(scannedUrl!)); } } } diff --git a/lib/src/visual/views/chats/add_new_user.view.dart b/lib/src/visual/views/chats/add_new_user.view.dart index 3c8eb10a..31df9348 100644 --- a/lib/src/visual/views/chats/add_new_user.view.dart +++ b/lib/src/visual/views/chats/add_new_user.view.dart @@ -9,6 +9,7 @@ import 'package:go_router/go_router.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/database/daos/user_discovery.dao.dart'; +import 'package:twonly/src/database/tables/contacts.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/services/api/utils.api.dart'; import 'package:twonly/src/utils/misc.dart'; @@ -130,13 +131,26 @@ class _SearchUsernameView extends State { requested: const Value(false), blocked: const Value(false), deletedByUser: const Value(false), - verified: Value( - !(widget.publicKey == null) && - userdata.publicIdentityKey.equals(widget.publicKey!), - ), ), ); + if (widget.publicKey != null && + mounted && + widget.publicKey!.equals(userdata.publicIdentityKey)) { + final markAsVerified = await showAlertDialog( + context, + context.lang.linkFromUsername(username), + context.lang.linkFromUsernameLong, + customOk: context.lang.gotLinkFromFriend, + ); + if (markAsVerified) { + await twonlyDB.keyVerificationDao.addKeyVerification( + userdata.userId.toInt(), + VerificationType.link, + ); + } + } + if (added > 0) await importSignalContactAndCreateRequest(userdata); } diff --git a/lib/src/visual/views/chats/chat_messages_components/entries/chat_contacts.entry.dart b/lib/src/visual/views/chats/chat_messages_components/entries/chat_contacts.entry.dart index 4cc9a919..d605a3d6 100644 --- a/lib/src/visual/views/chats/chat_messages_components/entries/chat_contacts.entry.dart +++ b/lib/src/visual/views/chats/chat_messages_components/entries/chat_contacts.entry.dart @@ -1,11 +1,13 @@ import 'dart:convert'; +import 'package:collection/collection.dart'; import 'package:drift/drift.dart' show Value; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:go_router/go_router.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/constants/routes.keys.dart'; +import 'package:twonly/src/database/tables/contacts.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart'; import 'package:twonly/src/services/api/utils.api.dart'; @@ -120,13 +122,18 @@ class _ContactRowState extends State<_ContactRow> { ); if (userdata == null) return; - var verified = false; - if (userdata.publicIdentityKey == widget.contact.publicIdentityKey) { - final sender = await twonlyDB.contactsDao.getContactById( + if (userdata.publicIdentityKey.equals(widget.contact.publicIdentityKey)) { + final verified = await twonlyDB.keyVerificationDao.isContactVerified( widget.message.senderId!, ); - // in case the sender is verified and the public keys are the same, this trust can be transferred - verified = sender != null && sender.verified; + + if (verified) { + Log.info('Verified a user which was shared by a verified contact'); + await twonlyDB.keyVerificationDao.addKeyVerification( + userdata.userId.toInt(), + VerificationType.contactSharedByVerified, + ); + } } final added = await twonlyDB.contactsDao.insertOnConflictUpdate( @@ -136,9 +143,6 @@ class _ContactRowState extends State<_ContactRow> { requested: const Value(false), blocked: const Value(false), deletedByUser: const Value(false), - verified: Value( - verified, - ), ), ); diff --git a/lib/src/visual/views/chats/media_viewer.view.dart b/lib/src/visual/views/chats/media_viewer.view.dart index a254c56d..4f604830 100644 --- a/lib/src/visual/views/chats/media_viewer.view.dart +++ b/lib/src/visual/views/chats/media_viewer.view.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:go_router/go_router.dart'; import 'package:lottie/lottie.dart'; +import 'package:mutex/mutex.dart'; import 'package:no_screenshot/no_screenshot.dart'; import 'package:photo_view/photo_view.dart'; import 'package:twonly/locator.dart'; @@ -103,43 +104,47 @@ class _MediaViewerViewState extends State { super.dispose(); } + final Mutex _messageUpdateLock = Mutex(); + Future asyncLoadNextMedia(bool firstRun) async { final messages = twonlyDB.messagesDao.watchMediaNotOpened( widget.group.groupId, ); _subscription = messages.listen((messages) async { - for (final msg in messages) { - if (_alreadyOpenedMediaIds.contains(msg.mediaId)) { - continue; - } - if (msg.mediaId == null) { - continue; - } + await _messageUpdateLock.protect(() async { + for (final msg in messages) { + if (_alreadyOpenedMediaIds.contains(msg.mediaId)) { + continue; + } + if (msg.mediaId == null) { + continue; + } - if (msg.mediaId == currentMedia?.mediaFile.mediaId) { - // The update of the current Media in case of a download is done in loadCurrentMediaFile - continue; + if (msg.mediaId == currentMedia?.mediaFile.mediaId) { + // The update of the current Media in case of a download is done in loadCurrentMediaFile + continue; + } + + /// If the messages was already there just replace it and go to the next... + + final index = allMediaFiles.indexWhere( + (m) => m.messageId == msg.messageId, + ); + + if (index >= 1) { + allMediaFiles[index] = msg; + } else if (index == -1) { + // If the message does not exist, add it + allMediaFiles.add(msg); + } } - - /// If the messages was already there just replace it and go to the next... - - final index = allMediaFiles.indexWhere( - (m) => m.messageId == msg.messageId, - ); - - if (index >= 1) { - allMediaFiles[index] = msg; - } else if (index == -1) { - // If the message does not exist, add it - allMediaFiles.add(msg); + setState(() {}); + if (firstRun) { + firstRun = false; + await loadCurrentMediaFile(); } - } - setState(() {}); - if (firstRun) { - firstRun = false; - await loadCurrentMediaFile(); - } + }); }); } diff --git a/lib/src/visual/views/contact/contact.view.dart b/lib/src/visual/views/contact/contact.view.dart index e9748665..81d0981e 100644 --- a/lib/src/visual/views/contact/contact.view.dart +++ b/lib/src/visual/views/contact/contact.view.dart @@ -1,13 +1,16 @@ import 'dart:async'; import 'package:drift/drift.dart'; +import 'package:intl/intl.dart'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:go_router/go_router.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart'; +import 'package:twonly/src/database/tables/contacts.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/components/alert.dialog.dart'; import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; @@ -30,24 +33,38 @@ class ContactView extends StatefulWidget { class _ContactViewState extends State { Contact? _contact; List _memberOfGroups = []; + List _keyVerifications = []; - late StreamSubscription _contactSub; + late StreamSubscription<(Contact, bool)?> _contactSub; late StreamSubscription> _groupMemberSub; + late StreamSubscription> _streamKeyVerifications; @override void initState() { - _contactSub = twonlyDB.contactsDao.watchContact(widget.userId).listen(( - update, - ) { - setState(() { - _contact = update; - }); - }); + _contactSub = twonlyDB.contactsDao + .watchContactAndVerificationState(widget.userId) + .listen(( + update, + ) { + if (update != null) { + setState(() { + _contact = update.$1; + }); + } + }); _groupMemberSub = twonlyDB.groupsDao .watchContactGroupMember(widget.userId) .listen((groups) async { _memberOfGroups = groups; }); + _streamKeyVerifications = twonlyDB.keyVerificationDao + .watchContactVerification(widget.userId) + .listen((update) { + setState(() { + Log.info('Verifications: ${update.length}'); + _keyVerifications = update; + }); + }); super.initState(); } @@ -55,6 +72,7 @@ class _ContactViewState extends State { void dispose() { _contactSub.cancel(); _groupMemberSub.cancel(); + _streamKeyVerifications.cancel(); super.dispose(); } @@ -214,7 +232,7 @@ class _ContactViewState extends State { RestoreFlameComp( contactId: widget.userId, ), - if (!contact.verified) + if (_keyVerifications.isEmpty) BetterListTile( leading: VerificationBadgeComp( contact: contact, @@ -226,6 +244,37 @@ class _ContactViewState extends State { setState(() {}); }, ), + if (_keyVerifications.isNotEmpty) + ExpansionTile( + shape: const RoundedRectangleBorder(), + backgroundColor: context.color.surfaceContainer, + collapsedShape: const RoundedRectangleBorder(), + leading: Padding( + padding: EdgeInsetsGeometry.only(left: 12, right: 12), + child: VerificationBadgeComp( + contact: contact, + size: 20, + ), + ), + title: Text(context.lang.userVerifiedTitle), + children: _keyVerifications + .map( + (kv) => ListTile( + dense: true, + title: Text(_verificationTypeLabel(context, kv.type)), + trailing: Text( + DateFormat.yMd( + Localizations.localeOf(context).toString(), + ).format(kv.createdAt), + style: TextStyle( + color: context.color.onSurfaceVariant, + fontSize: 13, + ), + ), + ), + ) + .toList(), + ), if (userService.currentUser.isUserDiscoveryEnabled) BetterListTile( icon: FontAwesomeIcons.usersViewfinder, @@ -279,6 +328,19 @@ class _ContactViewState extends State { } } +String _verificationTypeLabel(BuildContext context, VerificationType type) { + return switch (type) { + VerificationType.qrScanned => context.lang.verificationTypeQrScanned, + VerificationType.secretQrToken => + context.lang.verificationTypeSecretQrToken, + VerificationType.link => context.lang.verificationTypeLink, + VerificationType.contactSharedByVerified => + context.lang.verificationTypeContactSharedByVerified, + VerificationType.migratedFromOldVersion => + context.lang.verificationTypeMigratedFromOldVersion, + }; +} + Future showNicknameChangeDialog( BuildContext context, Contact contact, diff --git a/lib/src/visual/views/public_profile.view.dart b/lib/src/visual/views/public_profile.view.dart index 05aa2e0e..fd3b97c6 100644 --- a/lib/src/visual/views/public_profile.view.dart +++ b/lib/src/visual/views/public_profile.view.dart @@ -8,6 +8,7 @@ import 'package:qr_flutter/qr_flutter.dart'; import 'package:share_plus/share_plus.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/constants/routes.keys.dart'; +import 'package:twonly/src/services/signal/identity.signal.dart'; import 'package:twonly/src/utils/avatars.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/qr.dart'; @@ -21,7 +22,7 @@ class PublicProfileView extends StatefulWidget { } class _PublicProfileViewState extends State { - Uint8List? _qrCode; + String? _qrCode; Uint8List? _userAvatar; Uint8List? _publicKey; @@ -32,7 +33,7 @@ class _PublicProfileViewState extends State { } Future initAsync() async { - _qrCode = await getProfileQrCodeData(); + _qrCode = await QrCodeUtils.publicProfileLink(); _userAvatar = await getUserAvatar(); _publicKey = await getUserPublicKey(); if (mounted) setState(() {}); @@ -82,7 +83,7 @@ class _PublicProfileViewState extends State { ], ), child: QrImageView.withQr( - qr: QrCode.fromUint8List( + qr: QrCode.fromData( data: _qrCode!, errorCorrectLevel: QrErrorCorrectLevel.M, ), diff --git a/lib/src/visual/views/settings/privacy.view.dart b/lib/src/visual/views/settings/privacy.view.dart index 36099c04..7d1295b8 100644 --- a/lib/src/visual/views/settings/privacy.view.dart +++ b/lib/src/visual/views/settings/privacy.view.dart @@ -72,7 +72,7 @@ class _PrivacyViewState extends State { }, ), ListTile( - title: const Text('Freunde finden'), + title: Text(context.lang.userDiscoverySettingsTitle), onTap: () async { await context.push(Routes.settingsPrivacyUserDiscovery); setState(() {});