update the protocol

This commit is contained in:
otsmr 2026-07-19 14:25:27 +02:00
parent e4e1cf592d
commit 5258b8f78e
18 changed files with 407 additions and 225 deletions

View file

@ -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;
}

View file

@ -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<int>? pinSeed;
// Used to derive the key from the email/pin
List<int>? serverKeyProtection;
List<int>? 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<int>? encryptedServerKeyNonce;
// --->
// Checking with the server that the server data is valid and not delted throug the pin protection for example.

View file

@ -234,16 +234,12 @@ PasswordLessRecovery _$PasswordLessRecoveryFromJson(
Map<String, dynamic> json,
) => PasswordLessRecovery((json['threshold'] as num?)?.toInt() ?? 2)
..email = json['email'] as String?
..pinSeed = (json['pinSeed'] as List<dynamic>?)
..serverKeyProtection = (json['serverKeyProtection'] as List<dynamic>?)
?.map((e) => (e as num).toInt())
.toList()
..pinUnlockToken = (json['pinUnlockToken'] as List<dynamic>?)
?.map((e) => (e as num).toInt())
.toList()
..encryptedServerKeyNonce =
(json['encryptedServerKeyNonce'] as List<dynamic>?)
?.map((e) => (e as num).toInt())
.toList()
..lastServerHeartbeat = json['lastServerHeartbeat'] == null
? null
: DateTime.parse(json['lastServerHeartbeat'] as String)
@ -259,9 +255,8 @@ Map<String, dynamic> _$PasswordLessRecoveryToJson(
) => <String, dynamic>{
'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,

View file

@ -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();

View file

@ -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 = {

View file

@ -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);
}

View file

@ -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=');

View file

@ -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;
}

View file

@ -778,15 +778,15 @@ class ApiService {
Future<Result> getServerKeyForPasswordlessRecovery({
required int userId,
List<int>? encryptedServerKeyNone,
List<int>? serverKeyProtection,
List<int>? pinUnlockToken,
List<int>? 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<Result> submitRecoveryShare({
required String notificationId,
required List<int> encryptedMessage,
@ -954,7 +953,8 @@ class ApiService {
/// Polls the server for new passwordless recovery notification messages.
/// [alreadyReceivedIds] prevents the server from sending duplicates.
Future<server.Response_PasswordlessNotificationMessages?> checkForPasswordlessNotification({
Future<server.Response_PasswordlessNotificationMessages?>
checkForPasswordlessNotification({
required String notificationId,
required List<int> downloadAuthToken,
List<Int64>? alreadyReceivedIds,

View file

@ -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<void> 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(

View file

@ -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<bool> 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<void> 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);
}
}
}

View file

@ -9,8 +9,51 @@ import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/utils/log.dart';
const defaultAvatarSvg = '''
<!-- Taken from: https://getavataaars.com/ -->
<svg width="264px" height="280px" viewBox="0 0 264 280" version="1.1" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<path
d="M124,144.610951 L124,163 L128,163 L128,163 C167.764502,163 200,195.235498 200,235 L200,244 L0,244 L0,235 C-4.86974701e-15,195.235498 32.235498,163 72,163 L72,163 L76,163 L76,144.610951 C58.7626345,136.422372 46.3722246,119.687011 44.3051388,99.8812385 C38.4803105,99.0577866 34,94.0521096 34,88 L34,74 C34,68.0540074 38.3245733,63.1180731 44,62.1659169 L44,56 L44,56 C44,25.072054 69.072054,5.68137151e-15 100,0 L100,0 L100,0 C130.927946,-5.68137151e-15 156,25.072054 156,56 L156,62.1659169 C161.675427,63.1180731 166,68.0540074 166,74 L166,88 C166,94.0521096 161.51969,99.0577866 155.694861,99.8812385 C153.627775,119.687011 141.237365,136.422372 124,144.610951 Z"
id="react-path-3"></path>
</defs>
<g id="Avataaar" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g transform="translate(-825.000000, -1100.000000)">
<g transform="translate(825.000000, 1100.000000)">
<g id="Avataaar" stroke-width="1" fill-rule="evenodd">
<g id="Body" transform="translate(32.000000, 36.000000)">
<mask id="react-mask-6" fill="white">
<use xlink:href="#react-path-3"></use>
</mask>
<g id="Skin/👶🏽-03-Brown" mask="url(#react-mask-6)" fill="#57CC99">
<g transform="translate(0.000000, 0.000000)" id="Color">
<rect x="0" y="0" width="264" height="280"></rect>
</g>
</g>
</g>
</g>
</g>
</g>
</g>
</svg>''';
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<void> createPushAvatars({int? forceForUserId}) async {

View file

@ -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<RecoverPasswordless> {
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<RecoverPasswordless> {
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<RecoverPasswordless> {
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<RecoverPasswordless> {
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<RecoverPasswordless> {
),
)
else if (_reconstructedSecret != null) ...[
if (_reconstructedSecret!.hasPinSeed()) ...[
if (_reconstructedSecret!.hasServerKeyProtection() &&
!_reconstructedSecret!.hasEmailHint()) ...[
MyInput(
controller: _secondFactorController,
hintText: context.lang.passwordlessRecoveryMethodPinHint,

View file

@ -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(

View file

@ -69,7 +69,7 @@ class _PasswordLessRecoverySetupState extends State<PasswordLessRecoverySetup> {
_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 {

170
proto.md Normal file
View file

@ -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
<p style="background: #121314; padding: 15px 20px; margin-bottom: 20px;">
<strong>Update (July 19, 2026):</strong> 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.
</p>
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<br/>serverKey (32 bytes)<br/>serverKeyProtection (32 bytes)<br/>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<br/>serverKey (32 bytes)<br/>serverKeyProtection (32 bytes)<br/>pinUnlockToken (32 bytes)<br/>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:<br/>RecoveryData +<br/>(optional: serverKeyProtection + pinUnlockToken)
U->>U: Shamir split SharedSecretData<br/>into N shares (threshold T)
U->>F: Send each share via<br/>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<br/>notificationID (UUID)<br/>downloadAuthToken (32 bytes)<br/>notificationKey (32 bytes)
R->>R: Read out pushToken
R->>S: notificationID, downloadAuthToken,<br/>pushToken
R->>R: Creates link (behind fragment):<br/>notificationID, notificationKey
R->>F: Shows QR code or shares link
F->>APP: Opens recovery link / scans QR
APP->>APP: Parse notificationId +<br/>notificationKey from URL
APP->>F: "Select which contact is recovering"
F->>APP: Selects contact from list
APP->>F: "Are you sure?" phishing-prevention dialog<br/>with a 10s timer
F->>APP: Confirms
APP->>APP: Encrypt stored share + userId<br/>with notificationKey<br/>(XChaCha20-Poly1305)
APP->>S: Submit encrypted envelope<br/>(notificationId)
S->>R: Push notification:<br/>"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<br/>(authenticated via downloadAuthToken)
R->>R: Decrypt userId + shares<br/>with notificationKey
R->>R: Feed T shares into<br/>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<br/>with serverKey
else PIN second factor
R->>R: pinKey = HKDF(ikm=SharedSecretData.serverKeyProtection,<br/>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)<br/>(~10 tries max)
S->>R: Return serverKey
R->>R: Decrypt RecoveryData<br/>with serverKey
else No second factor
R->>R: RecoveryData is directly<br/>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.

View file

@ -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(

View file

@ -502,6 +502,7 @@ void main() {
final msg = pb.EncryptedContent_PasswordLessRecovery()
..delete = false
..threshold = Int64(2)
..recoverySecretShare = [11, 22, 33];
await PasswordlessRecoveryService.handlePasswordlessRecovery(