diff --git a/lib/globals.dart b/lib/globals.dart index 83197b22..4b476b6b 100644 --- a/lib/globals.dart +++ b/lib/globals.dart @@ -35,6 +35,6 @@ class AppState { static bool isInBackgroundTask = false; static bool allowErrorTrackingViaSentry = false; static bool gotMessageFromServer = false; - static int latestAppVersionId = 117; + static int latestAppVersionId = 118; static bool hasCameraPermissions = false; } diff --git a/lib/src/model/json/userdata.model.dart b/lib/src/model/json/userdata.model.dart index cf245035..10118bcd 100644 --- a/lib/src/model/json/userdata.model.dart +++ b/lib/src/model/json/userdata.model.dart @@ -217,18 +217,13 @@ class PasswordLessRecovery { // Only stored, so the user can see his deposit email address... String? email; - // <-- // Data shared with trusted friends @JsonKey(defaultValue: 2) int threshold; - // Trusted friends are able to brute-force the pin -> Server delets after X tries - List? pinSeed; + // Used to derive the key from the email/pin + List? serverKeyProtection; List? pinUnlockToken; - - // Stored not on the server, so the server is unable to link a email to a user until the actuall recovery or can - // brute-force the pin - List? encryptedServerKeyNonce; // ---> // Checking with the server that the server data is valid and not delted throug the pin protection for example. diff --git a/lib/src/model/json/userdata.model.g.dart b/lib/src/model/json/userdata.model.g.dart index 13e3565a..b8105cec 100644 --- a/lib/src/model/json/userdata.model.g.dart +++ b/lib/src/model/json/userdata.model.g.dart @@ -234,16 +234,12 @@ PasswordLessRecovery _$PasswordLessRecoveryFromJson( Map json, ) => PasswordLessRecovery((json['threshold'] as num?)?.toInt() ?? 2) ..email = json['email'] as String? - ..pinSeed = (json['pinSeed'] as List?) + ..serverKeyProtection = (json['serverKeyProtection'] as List?) ?.map((e) => (e as num).toInt()) .toList() ..pinUnlockToken = (json['pinUnlockToken'] as List?) ?.map((e) => (e as num).toInt()) .toList() - ..encryptedServerKeyNonce = - (json['encryptedServerKeyNonce'] as List?) - ?.map((e) => (e as num).toInt()) - .toList() ..lastServerHeartbeat = json['lastServerHeartbeat'] == null ? null : DateTime.parse(json['lastServerHeartbeat'] as String) @@ -259,9 +255,8 @@ Map _$PasswordLessRecoveryToJson( ) => { 'email': instance.email, 'threshold': instance.threshold, - 'pinSeed': instance.pinSeed, + 'serverKeyProtection': instance.serverKeyProtection, 'pinUnlockToken': instance.pinUnlockToken, - 'encryptedServerKeyNonce': instance.encryptedServerKeyNonce, 'lastServerHeartbeat': instance.lastServerHeartbeat?.toIso8601String(), 'lastContactHeartbeat': instance.lastContactHeartbeat?.toIso8601String(), 'encryptedServerKey': instance.encryptedServerKey, diff --git a/lib/src/model/protobuf/api/websocket/client_to_server.pb.dart b/lib/src/model/protobuf/api/websocket/client_to_server.pb.dart index 7ca11faa..b5ad1689 100644 --- a/lib/src/model/protobuf/api/websocket/client_to_server.pb.dart +++ b/lib/src/model/protobuf/api/websocket/client_to_server.pb.dart @@ -813,15 +813,15 @@ class Handshake_GetServerKeyForPasswordLessRecovery extends $pb.GeneratedMessage { factory Handshake_GetServerKeyForPasswordLessRecovery({ $fixnum.Int64? userId, - $core.List<$core.int>? encryptedServerKeyNone, + $core.List<$core.int>? serverKeyProtection, $core.List<$core.int>? pinUnlockToken, $core.List<$core.int>? pinProtectionKey, $core.String? email, }) { final result = create(); if (userId != null) result.userId = userId; - if (encryptedServerKeyNone != null) - result.encryptedServerKeyNone = encryptedServerKeyNone; + if (serverKeyProtection != null) + result.serverKeyProtection = serverKeyProtection; if (pinUnlockToken != null) result.pinUnlockToken = pinUnlockToken; if (pinProtectionKey != null) result.pinProtectionKey = pinProtectionKey; if (email != null) result.email = email; @@ -846,7 +846,7 @@ class Handshake_GetServerKeyForPasswordLessRecovery createEmptyInstance: create) ..aInt64(1, _omitFieldNames ? '' : 'userId') ..a<$core.List<$core.int>>( - 2, _omitFieldNames ? '' : 'encryptedServerKeyNone', $pb.PbFieldType.OY) + 2, _omitFieldNames ? '' : 'serverKeyProtection', $pb.PbFieldType.OY) ..a<$core.List<$core.int>>( 3, _omitFieldNames ? '' : 'pinUnlockToken', $pb.PbFieldType.OY) ..a<$core.List<$core.int>>( @@ -889,14 +889,13 @@ class Handshake_GetServerKeyForPasswordLessRecovery void clearUserId() => $_clearField(1); @$pb.TagNumber(2) - $core.List<$core.int> get encryptedServerKeyNone => $_getN(1); + $core.List<$core.int> get serverKeyProtection => $_getN(1); @$pb.TagNumber(2) - set encryptedServerKeyNone($core.List<$core.int> value) => - $_setBytes(1, value); + set serverKeyProtection($core.List<$core.int> value) => $_setBytes(1, value); @$pb.TagNumber(2) - $core.bool hasEncryptedServerKeyNone() => $_has(1); + $core.bool hasServerKeyProtection() => $_has(1); @$pb.TagNumber(2) - void clearEncryptedServerKeyNone() => $_clearField(2); + void clearServerKeyProtection() => $_clearField(2); @$pb.TagNumber(3) $core.List<$core.int> get pinUnlockToken => $_getN(2); @@ -1748,50 +1747,6 @@ class ApplicationData_GetAvailablePlans extends $pb.GeneratedMessage { static ApplicationData_GetAvailablePlans? _defaultInstance; } -class ApplicationData_GetAddAccountsInvites extends $pb.GeneratedMessage { - factory ApplicationData_GetAddAccountsInvites() => create(); - - ApplicationData_GetAddAccountsInvites._(); - - factory ApplicationData_GetAddAccountsInvites.fromBuffer( - $core.List<$core.int> data, - [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => - create()..mergeFromBuffer(data, registry); - factory ApplicationData_GetAddAccountsInvites.fromJson($core.String json, - [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => - create()..mergeFromJson(json, registry); - - static final $pb.BuilderInfo _i = $pb.BuilderInfo( - _omitMessageNames ? '' : 'ApplicationData.GetAddAccountsInvites', - package: - const $pb.PackageName(_omitMessageNames ? '' : 'client_to_server'), - createEmptyInstance: create) - ..hasRequiredFields = false; - - @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') - ApplicationData_GetAddAccountsInvites clone() => deepCopy(); - @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') - ApplicationData_GetAddAccountsInvites copyWith( - void Function(ApplicationData_GetAddAccountsInvites) updates) => - super.copyWith((message) => - updates(message as ApplicationData_GetAddAccountsInvites)) - as ApplicationData_GetAddAccountsInvites; - - @$core.override - $pb.BuilderInfo get info_ => _i; - - @$core.pragma('dart2js:noInline') - static ApplicationData_GetAddAccountsInvites create() => - ApplicationData_GetAddAccountsInvites._(); - @$core.override - ApplicationData_GetAddAccountsInvites createEmptyInstance() => create(); - @$core.pragma('dart2js:noInline') - static ApplicationData_GetAddAccountsInvites getDefault() => - _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor< - ApplicationData_GetAddAccountsInvites>(create); - static ApplicationData_GetAddAccountsInvites? _defaultInstance; -} - class ApplicationData_GetCurrentPlanInfos extends $pb.GeneratedMessage { factory ApplicationData_GetCurrentPlanInfos() => create(); diff --git a/lib/src/model/protobuf/api/websocket/client_to_server.pbjson.dart b/lib/src/model/protobuf/api/websocket/client_to_server.pbjson.dart index 8aa205e1..92051db5 100644 --- a/lib/src/model/protobuf/api/websocket/client_to_server.pbjson.dart +++ b/lib/src/model/protobuf/api/websocket/client_to_server.pbjson.dart @@ -339,11 +339,11 @@ const Handshake_GetServerKeyForPasswordLessRecovery$json = { '2': [ {'1': 'user_id', '3': 1, '4': 1, '5': 3, '10': 'userId'}, { - '1': 'encrypted_server_key_none', + '1': 'server_key_protection', '3': 2, '4': 1, '5': 12, - '10': 'encryptedServerKeyNone' + '10': 'serverKeyProtection' }, { '1': 'pin_unlock_token', @@ -462,20 +462,20 @@ final $typed_data.Uint8List handshakeDescriptor = $convert.base64Decode( 'VXaXRoTG9naW5Ub2tlbhIXCgd1c2VyX2lkGAEgASgDUgZ1c2VySWQSLAoSc2VjcmV0X2xvZ2lu' 'X3Rva2VuGAIgASgMUhBzZWNyZXRMb2dpblRva2VuEh8KC2FwcF92ZXJzaW9uGAMgASgJUgphcH' 'BWZXJzaW9uEhsKCWRldmljZV9pZBgEIAEoA1IIZGV2aWNlSWQSIwoNaW5fYmFja2dyb3VuZBgF' - 'IAEoCFIMaW5CYWNrZ3JvdW5kGqwCCiNHZXRTZXJ2ZXJLZXlGb3JQYXNzd29yZExlc3NSZWNvdm' - 'VyeRIXCgd1c2VyX2lkGAEgASgDUgZ1c2VySWQSOQoZZW5jcnlwdGVkX3NlcnZlcl9rZXlfbm9u' - 'ZRgCIAEoDFIWZW5jcnlwdGVkU2VydmVyS2V5Tm9uZRItChBwaW5fdW5sb2NrX3Rva2VuGAMgAS' - 'gMSABSDnBpblVubG9ja1Rva2VuiAEBEjEKEnBpbl9wcm90ZWN0aW9uX2tleRgEIAEoDEgBUhBw' - 'aW5Qcm90ZWN0aW9uS2V5iAEBEhkKBWVtYWlsGAUgASgJSAJSBWVtYWlsiAEBQhMKEV9waW5fdW' - '5sb2NrX3Rva2VuQhUKE19waW5fcHJvdGVjdGlvbl9rZXlCCAoGX2VtYWlsGssBCiBSZWdpc3Rl' - 'clBhc3N3b3JkbGVzc05vdGlmaWNhdGlvbhInCg9ub3RpZmljYXRpb25faWQYASABKAlSDm5vdG' - 'lmaWNhdGlvbklkEi4KE2Rvd25sb2FkX2F1dGhfdG9rZW4YAiABKAxSEWRvd25sb2FkQXV0aFRv' - 'a2VuEhsKCWxhbmdfY29kZRgDIAEoCVIIbGFuZ0NvZGUSIgoKZ29vZ2xlX2ZjbRgEIAEoCUgAUg' - 'lnb29nbGVGY22IAQFCDQoLX2dvb2dsZV9mY20avAEKIENoZWNrRm9yUGFzc3dvcmRsZXNzTm90' - 'aWZpY2F0aW9uEicKD25vdGlmaWNhdGlvbl9pZBgBIAEoCVIObm90aWZpY2F0aW9uSWQSLgoTZG' - '93bmxvYWRfYXV0aF90b2tlbhgCIAEoDFIRZG93bmxvYWRBdXRoVG9rZW4SPwocYWxyZWFkeV9y' - 'ZWNlaXZlZF9tZXNzYWdlX2lkcxgDIAMoA1IZYWxyZWFkeVJlY2VpdmVkTWVzc2FnZUlkc0ILCg' - 'lIYW5kc2hha2U='); + 'IAEoCFIMaW5CYWNrZ3JvdW5kGqUCCiNHZXRTZXJ2ZXJLZXlGb3JQYXNzd29yZExlc3NSZWNvdm' + 'VyeRIXCgd1c2VyX2lkGAEgASgDUgZ1c2VySWQSMgoVc2VydmVyX2tleV9wcm90ZWN0aW9uGAIg' + 'ASgMUhNzZXJ2ZXJLZXlQcm90ZWN0aW9uEi0KEHBpbl91bmxvY2tfdG9rZW4YAyABKAxIAFIOcG' + 'luVW5sb2NrVG9rZW6IAQESMQoScGluX3Byb3RlY3Rpb25fa2V5GAQgASgMSAFSEHBpblByb3Rl' + 'Y3Rpb25LZXmIAQESGQoFZW1haWwYBSABKAlIAlIFZW1haWyIAQFCEwoRX3Bpbl91bmxvY2tfdG' + '9rZW5CFQoTX3Bpbl9wcm90ZWN0aW9uX2tleUIICgZfZW1haWwaywEKIFJlZ2lzdGVyUGFzc3dv' + 'cmRsZXNzTm90aWZpY2F0aW9uEicKD25vdGlmaWNhdGlvbl9pZBgBIAEoCVIObm90aWZpY2F0aW' + '9uSWQSLgoTZG93bmxvYWRfYXV0aF90b2tlbhgCIAEoDFIRZG93bmxvYWRBdXRoVG9rZW4SGwoJ' + 'bGFuZ19jb2RlGAMgASgJUghsYW5nQ29kZRIiCgpnb29nbGVfZmNtGAQgASgJSABSCWdvb2dsZU' + 'ZjbYgBAUINCgtfZ29vZ2xlX2ZjbRq8AQogQ2hlY2tGb3JQYXNzd29yZGxlc3NOb3RpZmljYXRp' + 'b24SJwoPbm90aWZpY2F0aW9uX2lkGAEgASgJUg5ub3RpZmljYXRpb25JZBIuChNkb3dubG9hZF' + '9hdXRoX3Rva2VuGAIgASgMUhFkb3dubG9hZEF1dGhUb2tlbhI/ChxhbHJlYWR5X3JlY2VpdmVk' + 'X21lc3NhZ2VfaWRzGAMgAygDUhlhbHJlYWR5UmVjZWl2ZWRNZXNzYWdlSWRzQgsKCUhhbmRzaG' + 'FrZQ=='); @$core.Deprecated('Use applicationDataDescriptor instead') const ApplicationData$json = { @@ -786,7 +786,6 @@ const ApplicationData$json = { ApplicationData_UpdateGoogleFcmToken$json, ApplicationData_GetUserById$json, ApplicationData_GetAvailablePlans$json, - ApplicationData_GetAddAccountsInvites$json, ApplicationData_GetCurrentPlanInfos$json, ApplicationData_RemoveAdditionalUser$json, ApplicationData_GetPrekeysByUserId$json, @@ -871,11 +870,6 @@ const ApplicationData_GetAvailablePlans$json = { '1': 'GetAvailablePlans', }; -@$core.Deprecated('Use applicationDataDescriptor instead') -const ApplicationData_GetAddAccountsInvites$json = { - '1': 'GetAddAccountsInvites', -}; - @$core.Deprecated('Use applicationDataDescriptor instead') const ApplicationData_GetCurrentPlanInfos$json = { '1': 'GetCurrentPlanInfos', @@ -1130,30 +1124,30 @@ final $typed_data.Uint8List applicationDataDescriptor = $convert.base64Decode( 'VXNlcm5hbWUSGgoIdXNlcm5hbWUYASABKAlSCHVzZXJuYW1lGiwKDkNoYW5nZVVzZXJuYW1lEh' 'oKCHVzZXJuYW1lGAEgASgJUgh1c2VybmFtZRo1ChRVcGRhdGVHb29nbGVGY21Ub2tlbhIdCgpn' 'b29nbGVfZmNtGAEgASgJUglnb29nbGVGY20aJgoLR2V0VXNlckJ5SWQSFwoHdXNlcl9pZBgBIA' - 'EoA1IGdXNlcklkGhMKEUdldEF2YWlsYWJsZVBsYW5zGhcKFUdldEFkZEFjY291bnRzSW52aXRl' - 'cxoVChNHZXRDdXJyZW50UGxhbkluZm9zGi8KFFJlbW92ZUFkZGl0aW9uYWxVc2VyEhcKB3VzZX' - 'JfaWQYASABKANSBnVzZXJJZBotChJHZXRQcmVrZXlzQnlVc2VySWQSFwoHdXNlcl9pZBgBIAEo' - 'A1IGdXNlcklkGjIKF0dldFNpZ25lZFByZUtleUJ5VXNlcklkEhcKB3VzZXJfaWQYASABKANSBn' - 'VzZXJJZBqbAQoSVXBkYXRlU2lnbmVkUHJlS2V5EigKEHNpZ25lZF9wcmVrZXlfaWQYASABKANS' - 'DnNpZ25lZFByZWtleUlkEiMKDXNpZ25lZF9wcmVrZXkYAiABKAxSDHNpZ25lZFByZWtleRI2Ch' - 'dzaWduZWRfcHJla2V5X3NpZ25hdHVyZRgDIAEoDFIVc2lnbmVkUHJla2V5U2lnbmF0dXJlGjUK' - 'DERvd25sb2FkRG9uZRIlCg5kb3dubG9hZF90b2tlbhgBIAEoDFINZG93bmxvYWRUb2tlbhpOCg' - 'pSZXBvcnRVc2VyEigKEHJlcG9ydGVkX3VzZXJfaWQYASABKANSDnJlcG9ydGVkVXNlcklkEhYK' - 'BnJlYXNvbhgCIAEoCVIGcmVhc29uGnEKC0lQQVB1cmNoYXNlEh0KCnByb2R1Y3RfaWQYASABKA' - 'lSCXByb2R1Y3RJZBIWCgZzb3VyY2UYAiABKAlSBnNvdXJjZRIrChF2ZXJpZmljYXRpb25fZGF0' - 'YRgDIAEoCVIQdmVyaWZpY2F0aW9uRGF0YRoPCg1JUEFGb3JjZUNoZWNrGg8KDURlbGV0ZUFjY2' - '91bnQaLAoRQWRkQWRkaXRpb25hbFVzZXISFwoHdXNlcl9pZBgBIAEoA1IGdXNlcklkGjAKDVNl' - 'dExvZ2luVG9rZW4SHwoLbG9naW5fdG9rZW4YASABKAxSCmxvZ2luVG9rZW4aDAoKRGVwcmVjYX' - 'RlZBqOAQocUmVnaXN0ZXJQYXNzd29yZExlc3NSZWNvdmVyeRIuChJlbmNyeXB0ZWRTZXJ2ZXJL' - 'ZXkYASABKAxSEmVuY3J5cHRlZFNlcnZlcktleRIrCg5waW5VbmxvY2tUb2tlbhgCIAEoDEgAUg' - '5waW5VbmxvY2tUb2tlbogBAUIRCg9fcGluVW5sb2NrVG9rZW4acAoYUGFzc3dvcmRsZXNzTm90' - 'aWZpY2F0aW9uEicKD25vdGlmaWNhdGlvbl9pZBgBIAEoCVIObm90aWZpY2F0aW9uSWQSKwoRZW' - '5jcnlwdGVkX21lc3NhZ2UYAiABKAxSEGVuY3J5cHRlZE1lc3NhZ2UaUAoVUmVxdWVzdE1lbW9y' - 'aWVzVXBsb2FkEhIKBHNpemUYASABKANSBHNpemUSIwoNb3JpZ2luYWxfZGF0ZRgCIAEoA1IMb3' - 'JpZ2luYWxEYXRlGjIKFUNvbmZpcm1NZW1vcmllc1VwbG9hZBIZCghtZWRpYV9pZBgBIAEoCVIH' - 'bWVkaWFJZBpICg9HZXRNZW1vcmllc0xpc3QSHwoLb2Zmc2V0X2RhdGUYASABKANSCm9mZnNldE' - 'RhdGUSFAoFbGltaXQYAiABKANSBWxpbWl0GisKDkdldE1lbW9yaWVzVXJsEhkKCG1lZGlhX2lk' - 'GAEgASgJUgdtZWRpYUlkGhIKEEdldE1lbW9yaWVzVXNhZ2VCEQoPQXBwbGljYXRpb25EYXRh'); + 'EoA1IGdXNlcklkGhMKEUdldEF2YWlsYWJsZVBsYW5zGhUKE0dldEN1cnJlbnRQbGFuSW5mb3Ma' + 'LwoUUmVtb3ZlQWRkaXRpb25hbFVzZXISFwoHdXNlcl9pZBgBIAEoA1IGdXNlcklkGi0KEkdldF' + 'ByZWtleXNCeVVzZXJJZBIXCgd1c2VyX2lkGAEgASgDUgZ1c2VySWQaMgoXR2V0U2lnbmVkUHJl' + 'S2V5QnlVc2VySWQSFwoHdXNlcl9pZBgBIAEoA1IGdXNlcklkGpsBChJVcGRhdGVTaWduZWRQcm' + 'VLZXkSKAoQc2lnbmVkX3ByZWtleV9pZBgBIAEoA1IOc2lnbmVkUHJla2V5SWQSIwoNc2lnbmVk' + 'X3ByZWtleRgCIAEoDFIMc2lnbmVkUHJla2V5EjYKF3NpZ25lZF9wcmVrZXlfc2lnbmF0dXJlGA' + 'MgASgMUhVzaWduZWRQcmVrZXlTaWduYXR1cmUaNQoMRG93bmxvYWREb25lEiUKDmRvd25sb2Fk' + 'X3Rva2VuGAEgASgMUg1kb3dubG9hZFRva2VuGk4KClJlcG9ydFVzZXISKAoQcmVwb3J0ZWRfdX' + 'Nlcl9pZBgBIAEoA1IOcmVwb3J0ZWRVc2VySWQSFgoGcmVhc29uGAIgASgJUgZyZWFzb24acQoL' + 'SVBBUHVyY2hhc2USHQoKcHJvZHVjdF9pZBgBIAEoCVIJcHJvZHVjdElkEhYKBnNvdXJjZRgCIA' + 'EoCVIGc291cmNlEisKEXZlcmlmaWNhdGlvbl9kYXRhGAMgASgJUhB2ZXJpZmljYXRpb25EYXRh' + 'Gg8KDUlQQUZvcmNlQ2hlY2saDwoNRGVsZXRlQWNjb3VudBosChFBZGRBZGRpdGlvbmFsVXNlch' + 'IXCgd1c2VyX2lkGAEgASgDUgZ1c2VySWQaMAoNU2V0TG9naW5Ub2tlbhIfCgtsb2dpbl90b2tl' + 'bhgBIAEoDFIKbG9naW5Ub2tlbhoMCgpEZXByZWNhdGVkGo4BChxSZWdpc3RlclBhc3N3b3JkTG' + 'Vzc1JlY292ZXJ5Ei4KEmVuY3J5cHRlZFNlcnZlcktleRgBIAEoDFISZW5jcnlwdGVkU2VydmVy' + 'S2V5EisKDnBpblVubG9ja1Rva2VuGAIgASgMSABSDnBpblVubG9ja1Rva2VuiAEBQhEKD19waW' + '5VbmxvY2tUb2tlbhpwChhQYXNzd29yZGxlc3NOb3RpZmljYXRpb24SJwoPbm90aWZpY2F0aW9u' + 'X2lkGAEgASgJUg5ub3RpZmljYXRpb25JZBIrChFlbmNyeXB0ZWRfbWVzc2FnZRgCIAEoDFIQZW' + '5jcnlwdGVkTWVzc2FnZRpQChVSZXF1ZXN0TWVtb3JpZXNVcGxvYWQSEgoEc2l6ZRgBIAEoA1IE' + 'c2l6ZRIjCg1vcmlnaW5hbF9kYXRlGAIgASgDUgxvcmlnaW5hbERhdGUaMgoVQ29uZmlybU1lbW' + '9yaWVzVXBsb2FkEhkKCG1lZGlhX2lkGAEgASgJUgdtZWRpYUlkGkgKD0dldE1lbW9yaWVzTGlz' + 'dBIfCgtvZmZzZXRfZGF0ZRgBIAEoA1IKb2Zmc2V0RGF0ZRIUCgVsaW1pdBgCIAEoA1IFbGltaX' + 'QaKwoOR2V0TWVtb3JpZXNVcmwSGQoIbWVkaWFfaWQYASABKAlSB21lZGlhSWQaEgoQR2V0TWVt' + 'b3JpZXNVc2FnZUIRCg9BcHBsaWNhdGlvbkRhdGE='); @$core.Deprecated('Use responseDescriptor instead') const Response$json = { diff --git a/lib/src/model/protobuf/client/generated/passwordless_recovery.pb.dart b/lib/src/model/protobuf/client/generated/passwordless_recovery.pb.dart index ef98afd9..64551727 100644 --- a/lib/src/model/protobuf/client/generated/passwordless_recovery.pb.dart +++ b/lib/src/model/protobuf/client/generated/passwordless_recovery.pb.dart @@ -431,16 +431,14 @@ class RecoveryData extends $pb.GeneratedMessage { class SharedSecretData extends $pb.GeneratedMessage { factory SharedSecretData({ $core.List<$core.int>? recoveryData, - $core.List<$core.int>? encryptedServerKeyNonce, - $core.List<$core.int>? pinSeed, + $core.List<$core.int>? serverKeyProtection, $core.List<$core.int>? pinUnlockToken, $core.String? emailHint, }) { final result = create(); if (recoveryData != null) result.recoveryData = recoveryData; - if (encryptedServerKeyNonce != null) - result.encryptedServerKeyNonce = encryptedServerKeyNonce; - if (pinSeed != null) result.pinSeed = pinSeed; + if (serverKeyProtection != null) + result.serverKeyProtection = serverKeyProtection; if (pinUnlockToken != null) result.pinUnlockToken = pinUnlockToken; if (emailHint != null) result.emailHint = emailHint; return result; @@ -463,9 +461,7 @@ class SharedSecretData extends $pb.GeneratedMessage { ..a<$core.List<$core.int>>( 1, _omitFieldNames ? '' : 'recoveryData', $pb.PbFieldType.OY) ..a<$core.List<$core.int>>( - 3, _omitFieldNames ? '' : 'encryptedServerKeyNonce', $pb.PbFieldType.OY) - ..a<$core.List<$core.int>>( - 4, _omitFieldNames ? '' : 'pinSeed', $pb.PbFieldType.OY) + 3, _omitFieldNames ? '' : 'serverKeyProtection', $pb.PbFieldType.OY) ..a<$core.List<$core.int>>( 5, _omitFieldNames ? '' : 'pinUnlockToken', $pb.PbFieldType.OY) ..aOS(6, _omitFieldNames ? '' : 'emailHint') @@ -501,39 +497,29 @@ class SharedSecretData extends $pb.GeneratedMessage { void clearRecoveryData() => $_clearField(1); @$pb.TagNumber(3) - $core.List<$core.int> get encryptedServerKeyNonce => $_getN(1); + $core.List<$core.int> get serverKeyProtection => $_getN(1); @$pb.TagNumber(3) - set encryptedServerKeyNonce($core.List<$core.int> value) => - $_setBytes(1, value); + set serverKeyProtection($core.List<$core.int> value) => $_setBytes(1, value); @$pb.TagNumber(3) - $core.bool hasEncryptedServerKeyNonce() => $_has(1); + $core.bool hasServerKeyProtection() => $_has(1); @$pb.TagNumber(3) - void clearEncryptedServerKeyNonce() => $_clearField(3); - - @$pb.TagNumber(4) - $core.List<$core.int> get pinSeed => $_getN(2); - @$pb.TagNumber(4) - set pinSeed($core.List<$core.int> value) => $_setBytes(2, value); - @$pb.TagNumber(4) - $core.bool hasPinSeed() => $_has(2); - @$pb.TagNumber(4) - void clearPinSeed() => $_clearField(4); + void clearServerKeyProtection() => $_clearField(3); @$pb.TagNumber(5) - $core.List<$core.int> get pinUnlockToken => $_getN(3); + $core.List<$core.int> get pinUnlockToken => $_getN(2); @$pb.TagNumber(5) - set pinUnlockToken($core.List<$core.int> value) => $_setBytes(3, value); + set pinUnlockToken($core.List<$core.int> value) => $_setBytes(2, value); @$pb.TagNumber(5) - $core.bool hasPinUnlockToken() => $_has(3); + $core.bool hasPinUnlockToken() => $_has(2); @$pb.TagNumber(5) void clearPinUnlockToken() => $_clearField(5); @$pb.TagNumber(6) - $core.String get emailHint => $_getSZ(4); + $core.String get emailHint => $_getSZ(3); @$pb.TagNumber(6) - set emailHint($core.String value) => $_setString(4, value); + set emailHint($core.String value) => $_setString(3, value); @$pb.TagNumber(6) - $core.bool hasEmailHint() => $_has(4); + $core.bool hasEmailHint() => $_has(3); @$pb.TagNumber(6) void clearEmailHint() => $_clearField(6); } diff --git a/lib/src/model/protobuf/client/generated/passwordless_recovery.pbjson.dart b/lib/src/model/protobuf/client/generated/passwordless_recovery.pbjson.dart index 0a12fc1f..2df56b6b 100644 --- a/lib/src/model/protobuf/client/generated/passwordless_recovery.pbjson.dart +++ b/lib/src/model/protobuf/client/generated/passwordless_recovery.pbjson.dart @@ -116,21 +116,12 @@ const SharedSecretData$json = { '2': [ {'1': 'recovery_data', '3': 1, '4': 1, '5': 12, '10': 'recoveryData'}, { - '1': 'encrypted_server_key_nonce', + '1': 'server_key_protection', '3': 3, '4': 1, '5': 12, '9': 0, - '10': 'encryptedServerKeyNonce', - '17': true - }, - { - '1': 'pin_seed', - '3': 4, - '4': 1, - '5': 12, - '9': 1, - '10': 'pinSeed', + '10': 'serverKeyProtection', '17': true }, { @@ -138,7 +129,7 @@ const SharedSecretData$json = { '3': 5, '4': 1, '5': 12, - '9': 2, + '9': 1, '10': 'pinUnlockToken', '17': true }, @@ -147,14 +138,13 @@ const SharedSecretData$json = { '3': 6, '4': 1, '5': 9, - '9': 3, + '9': 2, '10': 'emailHint', '17': true }, ], '8': [ - {'1': '_encrypted_server_key_nonce'}, - {'1': '_pin_seed'}, + {'1': '_server_key_protection'}, {'1': '_pin_unlock_token'}, {'1': '_email_hint'}, ], @@ -163,8 +153,7 @@ const SharedSecretData$json = { /// Descriptor for `SharedSecretData`. Decode as a `google.protobuf.DescriptorProto`. final $typed_data.Uint8List sharedSecretDataDescriptor = $convert.base64Decode( 'ChBTaGFyZWRTZWNyZXREYXRhEiMKDXJlY292ZXJ5X2RhdGEYASABKAxSDHJlY292ZXJ5RGF0YR' - 'JAChplbmNyeXB0ZWRfc2VydmVyX2tleV9ub25jZRgDIAEoDEgAUhdlbmNyeXB0ZWRTZXJ2ZXJL' - 'ZXlOb25jZYgBARIeCghwaW5fc2VlZBgEIAEoDEgBUgdwaW5TZWVkiAEBEi0KEHBpbl91bmxvY2' - 'tfdG9rZW4YBSABKAxIAlIOcGluVW5sb2NrVG9rZW6IAQESIgoKZW1haWxfaGludBgGIAEoCUgD' - 'UgllbWFpbEhpbnSIAQFCHQobX2VuY3J5cHRlZF9zZXJ2ZXJfa2V5X25vbmNlQgsKCV9waW5fc2' - 'VlZEITChFfcGluX3VubG9ja190b2tlbkINCgtfZW1haWxfaGludA=='); + 'I3ChVzZXJ2ZXJfa2V5X3Byb3RlY3Rpb24YAyABKAxIAFITc2VydmVyS2V5UHJvdGVjdGlvbogB' + 'ARItChBwaW5fdW5sb2NrX3Rva2VuGAUgASgMSAFSDnBpblVubG9ja1Rva2VuiAEBEiIKCmVtYW' + 'lsX2hpbnQYBiABKAlIAlIJZW1haWxIaW50iAEBQhgKFl9zZXJ2ZXJfa2V5X3Byb3RlY3Rpb25C' + 'EwoRX3Bpbl91bmxvY2tfdG9rZW5CDQoLX2VtYWlsX2hpbnQ='); diff --git a/lib/src/model/protobuf/client/passwordless_recovery.proto b/lib/src/model/protobuf/client/passwordless_recovery.proto index 39243e19..ae295341 100644 --- a/lib/src/model/protobuf/client/passwordless_recovery.proto +++ b/lib/src/model/protobuf/client/passwordless_recovery.proto @@ -58,12 +58,9 @@ message SharedSecretData { // The recovery data is encrypted in case a second factor was chosen. bytes recovery_data = 1; - optional bytes encrypted_server_key_nonce = 3; + optional bytes server_key_protection = 3; - optional bytes pin_seed = 4; optional bytes pin_unlock_token = 5; optional string email_hint = 6; - } - diff --git a/lib/src/services/api.service.dart b/lib/src/services/api.service.dart index b173766f..f31cf0f2 100644 --- a/lib/src/services/api.service.dart +++ b/lib/src/services/api.service.dart @@ -778,15 +778,15 @@ class ApiService { Future getServerKeyForPasswordlessRecovery({ required int userId, - List? encryptedServerKeyNone, + List? serverKeyProtection, List? pinUnlockToken, List? pinProtectionKey, String? email, }) async { final get = Handshake_GetServerKeyForPasswordLessRecovery() ..userId = Int64(userId); - if (encryptedServerKeyNone != null) { - get.encryptedServerKeyNone = encryptedServerKeyNone; + if (serverKeyProtection != null) { + get.serverKeyProtection = serverKeyProtection; } if (pinUnlockToken != null) { get.pinUnlockToken = pinUnlockToken; @@ -803,7 +803,6 @@ class ApiService { return sendRequestSync(req, authenticated: false); } - Future submitRecoveryShare({ required String notificationId, required List encryptedMessage, @@ -954,7 +953,8 @@ class ApiService { /// Polls the server for new passwordless recovery notification messages. /// [alreadyReceivedIds] prevents the server from sending duplicates. - Future checkForPasswordlessNotification({ + Future + checkForPasswordlessNotification({ required String notificationId, required List downloadAuthToken, List? alreadyReceivedIds, diff --git a/lib/src/services/migrations.service.dart b/lib/src/services/migrations.service.dart index 4be4bfe1..f9cde856 100644 --- a/lib/src/services/migrations.service.dart +++ b/lib/src/services/migrations.service.dart @@ -13,6 +13,7 @@ import 'package:twonly/src/database/tables/contacts.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/json/signal_identity.model.dart'; import 'package:twonly/src/services/api/mediafiles/download.api.dart'; +import 'package:twonly/src/services/passwordless_recovery.service.dart'; import 'package:twonly/src/services/user.service.dart'; import 'package:twonly/src/services/user_discovery.service.dart'; import 'package:twonly/src/utils/log.dart'; @@ -170,9 +171,14 @@ Future runMigrations() async { }); } + if (userService.currentUser.appVersion < 118) { + await PasswordlessRecoveryService.migratePasswordlessRecovery(); + await UserService.update((u) => u.appVersion = 118); + } + if (kDebugMode) { assert( - AppState.latestAppVersionId == 117, + AppState.latestAppVersionId == 118, 'Forgot to update the target version in runMigrations() after incrementing AppState.latestAppVersionId.', ); assert( diff --git a/lib/src/services/passwordless_recovery.service.dart b/lib/src/services/passwordless_recovery.service.dart index ecca9060..0ac3d199 100644 --- a/lib/src/services/passwordless_recovery.service.dart +++ b/lib/src/services/passwordless_recovery.service.dart @@ -5,7 +5,7 @@ import 'package:clock/clock.dart'; import 'package:collection/collection.dart'; import 'package:crypto/crypto.dart' hide Hmac; import 'package:cryptography_plus/cryptography_plus.dart' - show Hmac, Mac, SecretBox, SecretKey, Xchacha20; + show Hkdf, Hmac, Mac, SecretBox, SecretKey, Xchacha20; import 'package:drift/drift.dart'; import 'package:fixnum/fixnum.dart'; import 'package:twonly/core/bridge/wrapper.dart'; @@ -24,7 +24,7 @@ import 'package:twonly/src/model/protobuf/client/generated/passwordless_recovery import 'package:twonly/src/providers/routing.provider.dart'; import 'package:twonly/src/services/api/messages.api.dart'; import 'package:twonly/src/services/user.service.dart'; -import 'package:twonly/src/utils/avatars.dart' show getAvatarSvg; + import 'package:twonly/src/utils/keyvalue.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; @@ -105,9 +105,7 @@ class PasswordlessRecoveryService { final shareUser = TrustedFriendShare_User( userId: Int64(contact.userId), displayName: getContactDisplayName(contact), - avatar: contact.avatarSvgCompressed != null - ? utf8.encode(getAvatarSvg(contact.avatarSvgCompressed!)) - : null, + avatar: contact.avatarSvgCompressed, ); final trustedFriendShare = TrustedFriendShare( @@ -187,20 +185,24 @@ class PasswordlessRecoveryService { switch (secondFactorType) { case SecondFactorType.email: - config.email = secondFactorValue; - emailHint = createEmailHint(secondFactorValue); + config.email = secondFactorValue.toLowerCase(); + emailHint = createEmailHint(config.email!); // E-Mail Protection: // - Server can only learn the email during recovery. Ensured as the server gets the NONCE to decrypt only during recovery. // - Trusted-friends: Server key is only sent to the mail, they would need access to the user's mail account. - secondFactorEncryptedServerKeyKey = SecretKey( - Uint8List.fromList(sha256.convert(utf8.encode(config.email!)).bytes), + config.serverKeyProtection = getRandomUint8List(32); + + final hkdf = Hkdf(hmac: Hmac.sha256(), outputLength: 32); + secondFactorEncryptedServerKeyKey = await hkdf.deriveKey( + secretKey: SecretKey(config.serverKeyProtection!), + nonce: utf8.encode(config.email!), ); case SecondFactorType.pin: // The pin seed - never shared with the server - ensures that the server is unable to brute-force real user's pin - config.pinSeed = getRandomUint8List(32); + config.serverKeyProtection = getRandomUint8List(32); // As the pin is heavily protected against brute-forcing e.g. will be deleted by the server after 10 tries, the // unlock token is required to prevent a malicious user (except the trusted friends) to trigger this deletion. @@ -210,16 +212,12 @@ class PasswordlessRecoveryService { // - Server: Does not know the seed. // - Trusted friends: Can only check the result 10 times before the server deletes the key. As they do not have // the mac and the cipher text they are unable to brute-force the pin locally. And the server only allows 10 tries. - final pinProtectionKey = await Hmac.sha256().calculateMac( - Uint8List.fromList(utf8.encode(secondFactorValue)), - secretKey: SecretKey(config.pinSeed!), + final hkdf = Hkdf(hmac: Hmac.sha256(), outputLength: 32); + secondFactorEncryptedServerKeyKey = await hkdf.deriveKey( + secretKey: SecretKey(config.serverKeyProtection!), + nonce: utf8.encode(secondFactorValue), ); - // To restore the user has to provide the server with this encryption key. The server then can verify the - // correct pin was entered, when decrypting the server key as he also receives the mac and nonce from the user - // Only when the mac is correct the server provides the user with the serverKey. - secondFactorEncryptedServerKeyKey = SecretKey(pinProtectionKey.bytes); - case SecondFactorType.none: } @@ -234,18 +232,16 @@ class PasswordlessRecoveryService { nonce: xchacha20.newNonce(), ); - // The server only gets the encrypted server key and the mac. Because the server does not know the nonce (192-bit - // because of XChaCha), he is unable to decrypt the server key without the help of the trusted friends. This - // ensures that the server never learns the user's original pin, as he is missing the pin_seed and also unable to - // brute-force the email of the user as he does not have the nonce. + // The server only gets the encrypted server key, the mac, and the nonce. + // This ensures that the server never learns the user's original pin, as he is missing the serverKeyProtection and also unable to + // brute-force the email of the user. encryptedServerKey = Uint8List.fromList([ ...secretBox.cipherText, ...secretBox.mac.bytes, + ...secretBox.nonce, ]); - config - ..encryptedServerKeyNonce = secretBox.nonce - ..encryptedServerKey = encryptedServerKey; + config.encryptedServerKey = encryptedServerKey; } // 3. Using shamir's secret to generate the shares for the users. @@ -275,10 +271,9 @@ class PasswordlessRecoveryService { final sharedSecretData = SharedSecretData( recoveryData: recoveryData, - pinSeed: config.pinSeed, + serverKeyProtection: config.serverKeyProtection, pinUnlockToken: config.pinUnlockToken, emailHint: emailHint, - encryptedServerKeyNonce: config.encryptedServerKeyNonce, ).writeToBuffer(); // 3.2. Use the amount of trusted friends to generate the shares @@ -322,29 +317,31 @@ class PasswordlessRecoveryService { static Future testPin(String pin) async { final config = userService.currentUser.passwordLessRecovery; - if (config?.pinSeed == null || config?.encryptedServerKey == null) { + if (config?.serverKeyProtection == null || + config?.encryptedServerKey == null) { return false; } try { - final pinProtectionKey = await Hmac.sha256().calculateMac( - Uint8List.fromList(utf8.encode(pin)), - secretKey: SecretKey(config!.pinSeed!), - ); - - final secondFactorEncryptedServerKeyKey = SecretKey( - pinProtectionKey.bytes, + final hkdf = Hkdf(hmac: Hmac.sha256(), outputLength: 32); + final secondFactorEncryptedServerKeyKey = await hkdf.deriveKey( + secretKey: SecretKey(config!.serverKeyProtection!), + nonce: utf8.encode(pin), ); final xchacha20 = Xchacha20.poly1305Aead(); final combined = config.encryptedServerKey!; - final cipherText = combined.sublist(0, combined.length - 16); - final macBytes = combined.sublist(combined.length - 16); + final nonceBytes = combined.sublist(combined.length - 24); + final macBytes = combined.sublist( + combined.length - 40, + combined.length - 24, + ); + final cipherText = combined.sublist(0, combined.length - 40); final secretBox = SecretBox( cipherText, - nonce: config.encryptedServerKeyNonce!, + nonce: nonceBytes, mac: Mac(macBytes), ); @@ -630,4 +627,53 @@ class PasswordlessRecoveryService { return didUpdate; } + + static Future migratePasswordlessRecovery() async { + final config = userService.currentUser.passwordLessRecovery; + if (config == null) return; + + final oldTrustedFriends = await (twonlyDB.select( + twonlyDB.contacts, + )..where((t) => t.recoveryIsTrustedFriend.equals(true))).get(); + final trustedFriendIds = oldTrustedFriends.map((e) => e.userId).toList(); + + if (trustedFriendIds.isEmpty) return; + + if (config.email != null) { + await enablePasswordlessRecovery( + trustedFriendIds: trustedFriendIds, + secondFactorType: SecondFactorType.email, + secondFactorValue: config.email!, + threshold: config.threshold, + ); + } else if (config.pinUnlockToken == null) { + await enablePasswordlessRecovery( + trustedFriendIds: trustedFriendIds, + secondFactorType: SecondFactorType.none, + secondFactorValue: '', + threshold: config.threshold, + ); + } else { + // It's PIN, we can't migrate it because we don't have the PIN. We delete it so the user has to do it again. + for (final contact in oldTrustedFriends) { + try { + await sendCipherText( + contact.userId, + pb.EncryptedContent( + passwordlessRecovery: pb.EncryptedContent_PasswordLessRecovery( + delete: true, + ), + ), + ); + } catch (e) { + Log.error( + 'Failed to send delete PasswordLessRecovery message to contact ${contact.userId}: $e', + ); + } + } + + await twonlyDB.contactsDao.resetRecoveryDataForAllContacts(); + await UserService.update((u) => u.passwordLessRecovery = null); + } + } } diff --git a/lib/src/utils/avatars.dart b/lib/src/utils/avatars.dart index 255a7253..8bfe349d 100644 --- a/lib/src/utils/avatars.dart +++ b/lib/src/utils/avatars.dart @@ -9,8 +9,51 @@ import 'package:twonly/globals.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/utils/log.dart'; +const defaultAvatarSvg = ''' + + + + + + + + + + + + + + + + + + + + + + + +'''; + String getAvatarSvg(Uint8List avatarSvgCompressed) { - return utf8.decode(gzip.decode(avatarSvgCompressed)); + try { + // Check for GZIP magic bytes (0x1F 0x8B) + final isGzip = + avatarSvgCompressed.length >= 2 && + avatarSvgCompressed[0] == 0x1F && + avatarSvgCompressed[1] == 0x8B; + + final decodedBytes = isGzip + ? gzip.decode(avatarSvgCompressed) + : avatarSvgCompressed; + return utf8.decode(decodedBytes); + } catch (e) { + Log.error('Failed to decode avatar SVG: $e'); + return defaultAvatarSvg; + } } Future createPushAvatars({int? forceForUserId}) async { diff --git a/lib/src/visual/views/onboarding/recover_passwordless.view.dart b/lib/src/visual/views/onboarding/recover_passwordless.view.dart index 045d54b6..bf5d3170 100644 --- a/lib/src/visual/views/onboarding/recover_passwordless.view.dart +++ b/lib/src/visual/views/onboarding/recover_passwordless.view.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:cryptography_plus/cryptography_plus.dart' - show Hmac, Mac, SecretBox, SecretKey, Xchacha20; + show Hkdf, Hmac, Mac, SecretBox, SecretKey, Xchacha20; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -21,6 +21,7 @@ import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pb.dart import 'package:twonly/src/model/protobuf/client/generated/passwordless_recovery.pb.dart'; import 'package:twonly/src/services/backup.service.dart'; import 'package:twonly/src/services/passwordless_recovery.service.dart'; +import 'package:twonly/src/utils/avatars.dart'; import 'package:twonly/src/utils/keyvalue.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; @@ -239,7 +240,8 @@ class _RecoverPasswordlessState extends State { final userId = shares.first.myUserId; Uint8List? serverKey; - if (reconstructed.hasPinSeed()) { + if (!reconstructed.hasEmailHint() && + reconstructed.hasServerKeyProtection()) { final pin = _secondFactorController.text.trim(); if (pin.isEmpty) { showSnackbar( @@ -252,18 +254,18 @@ class _RecoverPasswordlessState extends State { return; } - // Calculate pinProtectionKey - final pinProtectionKey = await Hmac.sha256().calculateMac( - Uint8List.fromList(utf8.encode(pin)), - secretKey: SecretKey(reconstructed.pinSeed), + // Calculate pinProtectionKey via Hkdf + final hkdf = Hkdf(hmac: Hmac.sha256(), outputLength: 32); + final pinKey = await hkdf.deriveKey( + secretKey: SecretKey(reconstructed.serverKeyProtection), + nonce: utf8.encode(pin), ); // Fetch serverKey final res = await apiService.getServerKeyForPasswordlessRecovery( userId: userId, pinUnlockToken: reconstructed.pinUnlockToken, - pinProtectionKey: pinProtectionKey.bytes, - encryptedServerKeyNone: reconstructed.encryptedServerKeyNonce, + pinProtectionKey: await pinKey.extractBytes(), ); if (res.isError) { @@ -303,7 +305,7 @@ class _RecoverPasswordlessState extends State { final res = await apiService.getServerKeyForPasswordlessRecovery( userId: userId, email: email, - encryptedServerKeyNone: reconstructed.encryptedServerKeyNonce, + serverKeyProtection: reconstructed.serverKeyProtection, ); if (res.isError) { @@ -490,7 +492,9 @@ class _RecoverPasswordlessState extends State { child: Column( children: [ AvatarIcon( - svg: utf8.decode(first.myAvatarSvg ?? []), + svg: first.myAvatarSvg != null + ? getAvatarSvg(Uint8List.fromList(first.myAvatarSvg!)) + : null, fontSize: 60, ), const SizedBox(height: 12), @@ -568,7 +572,8 @@ class _RecoverPasswordlessState extends State { ), ) else if (_reconstructedSecret != null) ...[ - if (_reconstructedSecret!.hasPinSeed()) ...[ + if (_reconstructedSecret!.hasServerKeyProtection() && + !_reconstructedSecret!.hasEmailHint()) ...[ MyInput( controller: _secondFactorController, hintText: context.lang.passwordlessRecoveryMethodPinHint, diff --git a/lib/src/visual/views/settings/backup/passwordless_recovery/settings.passwordless_recovery.view.dart b/lib/src/visual/views/settings/backup/passwordless_recovery/settings.passwordless_recovery.view.dart index 9afe306a..5dae377d 100644 --- a/lib/src/visual/views/settings/backup/passwordless_recovery/settings.passwordless_recovery.view.dart +++ b/lib/src/visual/views/settings/backup/passwordless_recovery/settings.passwordless_recovery.view.dart @@ -116,7 +116,7 @@ class PasswordLessRecoverySettings extends StatelessWidget { secondFactorLabel = context.lang .passwordlessRecoverySecondFactorEmailLabel(config.email!); secondFactorIcon = Icons.email_outlined; - } else if (config.pinSeed != null) { + } else if (config.serverKeyProtection != null && config.email == null) { secondFactorLabel = context.lang.passwordlessRecoverySecondFactorPin; secondFactorIcon = Icons.pin_outlined; actionButton = MyButton( diff --git a/lib/src/visual/views/settings/backup/passwordless_recovery/setup.passwordless_recovery.view.dart b/lib/src/visual/views/settings/backup/passwordless_recovery/setup.passwordless_recovery.view.dart index f1bdcd9e..22156a8f 100644 --- a/lib/src/visual/views/settings/backup/passwordless_recovery/setup.passwordless_recovery.view.dart +++ b/lib/src/visual/views/settings/backup/passwordless_recovery/setup.passwordless_recovery.view.dart @@ -69,7 +69,7 @@ class _PasswordLessRecoverySetupState extends State { _secondFactor = SecondFactorType.email; _emailController.text = config.email!; _threshold = 2; - } else if (config.pinSeed != null) { + } else if (config.serverKeyProtection != null && config.email == null) { _secondFactor = SecondFactorType.pin; _threshold = 2; } else { diff --git a/proto.md b/proto.md new file mode 100644 index 00000000..d8ac8c97 --- /dev/null +++ b/proto.md @@ -0,0 +1,170 @@ +--- +title: "Break my Passwordless Recovery Protocol and get a 50€ Bug-Bounty" +date: 2026-06-23 +description: I designed a passwordless recovery protocol using trusted friends and a server-side second factor for my privacy-focused messaging app twonly. To ensure its security before release, I am launching a 50€ bug bounty challenge for anyone who can find a vulnerability in the protocol. +keywords: twonly, Bug-Bounty, Protocol-Design +--- +# Break my Passwordless Recovery Protocol and get a 50€ Bug-Bounty + +A year ago I started building an Open-Source Snapchat alternative called [twonly](https://twonly.eu), as I liked the basic features of Snapchat, but wanted my images and text messages to not be [scanned](https://heise.de/-11246086), viewed by their [employees](https://www.vice.com/en/article/snapchat-employees-abused-data-access-spy-on-users-snaplion/), or potentially by attackers or [a government](https://www.bbc.com/news/world-europe-68099669). + +Directly from the start, users frequently lost access to their account after installing the app on a new device or losing their old phone. So a recovery mechanism was needed. Signal and WhatsApp use, beside their ([unencrypted (WhatsApp)](https://wire.com/en/blog/when-opt-in-security-fails-whatsapp-backup-example)) backup, the user's phone number to identify the user and allow them to reset their private key. + +But I do not want to use phone numbers, as this would undermine the [users' privacy](https://twonly.eu/en/blog/2026-mutual-friends.html#the-problem-with-phone-numbers). Also, allowing users to recover their accounts using solely a phone number moves the Root of Trust from the private key to the phone number and with that to the server, which claims to have verified it. The server can then notify contacts about the user's new public key, which the clients accept. Yes, the clients show "Your safety numbers have changed", but this is ignored by almost all users (from my own experience). + +As a starting point I decided to use a similar approach to [Threema's Safe](https://threema.com/en/faq/threema-safe-security), as the protocol was already designed and audited by experts. But this system requires the user to remember their password to protect the uploaded private key. And believe it or not, users frequently forget their password, again requiring them to recreate their account, losing all their friends, rejoining existing groups, and, in the case of an E2EE encrypted image backup which I am planning to implement, losing access to their precious images. To fix this I started designing a passwordless recovery protocol involving Trusted Friends and the server as a second factor. + +When involving friends to store a share of the user's secret key, it must be ensured that these are actually their friends' accounts and not an attacker impersonating their actual friends or a potentially compromised server performing an active MITM attack. For this, I already [rolled](https://twonly.eu/en/blog/2026-mutual-friends.html) out as part of [my master's thesis](https://mastodon.social/@twonly/115966775370449880) an improved Authentication Ceremony which users can more easily understand and which is partially automated via a Web of Trust approach. Users then can only select such verified friends. + +But as I want this protocol to be secure, it should ideally be verified by external security professionals. However, as a student, I cannot afford a professional audit. So before releasing it to the public, I am starting a 50€ Bug Bounty challenge with this blog post. The deal: **You find a flaw in my protocol, you receive 50€.** And yes, this may not sound like much, but hey, I am a student and have to pay this out of my own pocket. At the end of this post, you will find the rules and the security goals for the bug bounty. + +## The Passwordless Recovery Protocol + +

+Update (July 19, 2026): I received feedback from a cryptography expert, who pointed out that my original trick of withholding the nonce from the server might work in practice, but it prevents a formal security proof under standard AEAD models. So I updated the protocol: Instead of the nonce, the encryption key for the serverKey is now withheld, distributed to the trusted friends, and only sent to the server during the recovery. +

+ +To enable the passwordless recovery, a user first has to select at least **T + 2** verified trusted friends (N), where T is the threshold for [Shamir's Secret Sharing](https://en.wikipedia.org/wiki/Shamir%27s_secret_sharing). The + 2 is there so that even if a couple of friends lose their phone or become unreachable, there are still enough shares left to recover. Then they can decide to enable a second factor, either a PIN or an email. From there, the user has finished their task. In the background the user's secret key is then split into multiple parts and then distributed to the selected friends. In case the second factor was enabled, these shares are further encrypted using a server key. Here is the full setup flow: + +```mermaid +sequenceDiagram + participant U as User + participant S as Server + participant F as Friends + + U->>U: RecoveryData = userId + secret key + + alt Second Factor: Email + U->>U: Generate random
serverKey (32 bytes)
serverKeyProtection (32 bytes)
Nonce (24 bytes) + U->>U: emailKey = HKDF(ikm=serverKeyProtection, salt=email) + U->>U: protectedServerKey, MAC = XChaCha20-Poly1305.encrypt(serverKey, emailKey, Nonce) + U->>S: protectedServerKey + MAC + Nonce + U->>U: RecoveryData = XChaCha20-Poly1305.encrypt(RecoveryData, serverKey) + else Second Factor: PIN + U->>U: Generate random
serverKey (32 bytes)
serverKeyProtection (32 bytes)
pinUnlockToken (32 bytes)
Nonce (24 bytes) + U->>U: pinKey = HKDF(ikm=serverKeyProtection, salt=pin) + U->>U: protectedServerKey, MAC = XChaCha20-Poly1305.encrypt(serverKey, pinKey, Nonce) + U->>S: protectedServerKey + MAC + pinUnlockToken + Nonce + U->>U: RecoveryData = XChaCha20-Poly1305.encrypt(RecoveryData, serverKey) + else No Second Factor + U->>U: increase threshold T from >=2 to >=4 + end + + U->>U: Bundle SharedSecretData:
RecoveryData +
(optional: serverKeyProtection + pinUnlockToken) + + U->>U: Shamir split SharedSecretData
into N shares (threshold T) + + U->>F: Send each share via
E2E encrypted message + F->>F: Store share locally +``` + +Because the server does not receive the `serverKeyProtection` (32 bytes), the server is unable to brute-force the user's email or PIN. But because it has the MAC, it can then during recovery ensure that it received the original user's input. + + +### Recovery Flow + +When a user now forgets their backup password and has enabled the passwordless recovery, they can ask their friends to send them their shares. For this, the following flow is used to make it as easy as possible for all participants: + + +```mermaid +sequenceDiagram + participant R as Recovering User + participant S as Server + participant F as Trusted Friend + participant APP as Friend's App + + + R->>R: generates random
notificationID (UUID)
downloadAuthToken (32 bytes)
notificationKey (32 bytes) + + R->>R: Read out pushToken + + R->>S: notificationID, downloadAuthToken,
pushToken + + R->>R: Creates link (behind fragment):
notificationID, notificationKey + + R->>F: Shows QR code or shares link + + F->>APP: Opens recovery link / scans QR + APP->>APP: Parse notificationId +
notificationKey from URL + + APP->>F: "Select which contact is recovering" + F->>APP: Selects contact from list + APP->>F: "Are you sure?" phishing-prevention dialog
with a 10s timer + F->>APP: Confirms + + APP->>APP: Encrypt stored share + userId
with notificationKey
(XChaCha20-Poly1305) + APP->>S: Submit encrypted envelope
(notificationId) + S->>R: Push notification:
"A friend submitted a share" +``` + +A symmetric encryption key was deliberately chosen over an asymmetric keypair: the goal is to protect submitted shares from the server, not from the user. Only the user holding the downloadAuthToken can download the submissions, and only the one holding the notificationKey can decrypt them. This makes the protocol post-quantum safe from the start, without requiring large public keys which cannot be shared via a QR code or a link. + +To reduce potential phishing attacks, the user must manually select which friend they want to help, and then confirm their selection. This confirmation has a 10s timer and a red warning, asking them if they are sure that they have been asked by the selected user via a secure channel (like in-person or via Signal). + +In the last step, the recovering user now can use the collected shares to recover their secret key: + + +```mermaid +sequenceDiagram + participant R as Recovering User + participant S as Server + participant I as User's inbox + + R->>S: Download encrypted shares
(authenticated via downloadAuthToken) + R->>R: Decrypt userId + shares
with notificationKey + R->>R: Feed T shares into
Shamir reconstruction + R->>R: Obtain SharedSecretData + + alt Email second factor + R->>R: Inputs email + R->>S: Send userId, SharedSecretData.serverKeyProtection, email + S->>S: emailKey = HKDF(ikm=serverKeyProtection, salt=email) + S->>S: serverKey = XChaCha20-Poly1305.decrypt(protectedServerKey, emailKey, stored Nonce, stored MAC) + S->>I: Send serverKey + I->>R: Copy serverKey + R->>R: Decrypt RecoveryData
with serverKey + else PIN second factor + R->>R: pinKey = HKDF(ikm=SharedSecretData.serverKeyProtection,
salt=pin) + R->>S: Send userId, pinKey + SharedSecretData.pinUnlockToken + S->>S: Check pinUnlockToken + S->>S: serverKey = XChaCha20-Poly1305.decrypt(protectedServerKey, pinKey, stored Nonce, stored MAC)
(~10 tries max) + S->>R: Return serverKey + R->>R: Decrypt RecoveryData
with serverKey + else No second factor + R->>R: RecoveryData is directly
in SharedSecretData + end + + R->>R: Restore userId + secret key +``` + +The server will automatically delete the `protectedServerKey` when more than 10 tries for the PIN were performed. The reason for the `pinUnlockToken` is to prevent denial-of-service: without it, anyone could just spam the recovery endpoint with wrong keys and trigger the 10-attempt lockout, permanently destroying the user's `protectedServerKey`. Since only the trusted friends hold the Shamir shares containing the `pinUnlockToken`, only they (in case they collude) or the legitimate recovering user can actually initiate a recovery attempt. + +As this protocol is designed to only recover small amounts of data (the secret key), this is combined with an encrypted backup in the cloud, which is protected by the user's secret key that can then be downloaded by the user, restoring their contacts, messages and images. + +## Rules + +- Below is a list of my security goals; compromise at least one and you qualify for the 50€ Bug-Bounty. If you find another security issue or encounter any other problem, I'm all ears. +- You can ask your agent, but you must disclose that, as I am curious what you prompted :). +- First come, first served. The bug-bounty is limited to one payout. Sorry, I am a student :/ +- I reserve the right to decide for myself if the flaw is worth the bug bounty payout. +- The Bug-Bounty is only paid out in Cash, Bank-Transfer, or WERO. + +### Security Goals + +- The server should not be able to link a user and an email until the user recovers their account. +- The server should not be able to get access to the plaintext PIN. +- The server should never be able to get access to the user's secret key. +- A single trusted friend should never be able to recover the user's secret key on their own. +- The trusted friends should, in case the second factor is enabled, not be able to collude and recover the user's secret key. + +### Assumptions + +- The server does not collude with the trusted friends. +- The trusted friends are unable to guess the user's PIN with under 10 attempts. +- The trusted friends are unable to access the user's inbox. +- In case of no second factor, the trusted friends (at least 4) do not collude. + + +## Contact + +If you have found something, please send either an [email](mailto:security@tsmr.eu) or find me on Signal: tobi.02. \ No newline at end of file diff --git a/test/features/link_parser_test.dart b/test/features/link_parser_test.dart index 37d09e63..c48c2b31 100644 --- a/test/features/link_parser_test.dart +++ b/test/features/link_parser_test.dart @@ -42,7 +42,7 @@ void main() { image: 'https://files.mastodon.social/media_attachments/files/115/883/317/526/523/824/original/6fa7ef90ec68f1f1.jpg', vendor: Vendor.mastodonSocialMediaPosting, - shareAction: 80, + shareAction: 60, likeAction: 290, ), LinkParserTest( diff --git a/test/services/passwordless_recovery_service_test.dart b/test/services/passwordless_recovery_service_test.dart index 43d6d81c..91aaa851 100644 --- a/test/services/passwordless_recovery_service_test.dart +++ b/test/services/passwordless_recovery_service_test.dart @@ -502,6 +502,7 @@ void main() { final msg = pb.EncryptedContent_PasswordLessRecovery() ..delete = false + ..threshold = Int64(2) ..recoverySecretShare = [11, 22, 33]; await PasswordlessRecoveryService.handlePasswordlessRecovery(