store the mac on the server
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run

This commit is contained in:
otsmr 2026-06-21 22:01:40 +02:00
parent f8a2240992
commit ca77cc4902
8 changed files with 80 additions and 99 deletions

View file

@ -2159,15 +2159,14 @@ class AppLocalizationsDe extends AppLocalizations {
String get passwordlessRecoverySecondFactor => 'Zweiter Faktor'; String get passwordlessRecoverySecondFactor => 'Zweiter Faktor';
@override @override
String get passwordlessRecoveryNoFriendsFound => String get passwordlessRecoveryNoFriendsFound => 'Keine Freunde gefunden.';
'Keine vertrauenswürdigen Freunde gefunden.';
@override @override
String get passwordlessRecoveryActiveFriends => 'Aktive Freunde'; String get passwordlessRecoveryActiveFriends => 'Aktive Freunde';
@override @override
String get passwordlessRecoveryActiveFriendsDesc => String get passwordlessRecoveryActiveFriendsDesc =>
'Diese vertrauenswürdigen Freunde nutzen twonly aktiv und können dir wahrscheinlich helfen, dein Konto wiederherzustellen.'; 'Diese Freunde nutzen twonly aktiv und können dir wahrscheinlich helfen, dein Konto wiederherzustellen.';
@override @override
String get passwordlessRecoveryInactiveFriends => 'Inaktive Freunde'; String get passwordlessRecoveryInactiveFriends => 'Inaktive Freunde';
@ -2193,7 +2192,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get passwordlessRecoveryNeedAtLeast3 => String get passwordlessRecoveryNeedAtLeast3 =>
'Du brauchst mindestens 3 vertrauenswürdige Freunde.'; 'Du brauchst mindestens 3 Freunde.';
@override @override
String get passwordlessRecoveryInvalidPin => 'Ungültige PIN'; String get passwordlessRecoveryInvalidPin => 'Ungültige PIN';
@ -2217,7 +2216,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String passwordlessRecoveryStatusEnabled(num count) { String passwordlessRecoveryStatusEnabled(num count) {
return 'Aktiviert • $count vertrauenswürdige Freunde'; return 'Aktiviert • $count Freunde';
} }
@override @override
@ -2225,7 +2224,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get passwordlessRecoveryInfoHowItWorksDesc => String get passwordlessRecoveryInfoHowItWorksDesc =>
'Da twonly ohne zentrale Benutzerkonten oder Telefonnummern funktioniert, um deine Privatsphäre zu maximieren, setzen wir auf einen dezentralen Wiederherstellungsmechanismus. Mit Shamir\'s Secret Sharing wird deine kryptografische Identität in unabhängige Teile aufgeteilt und an deine vertrauenswürdigen Freunde verteilt. Um den Zugriff wiederherzustellen, muss ein vorher festgelegter Schwellenwert dieser Freunde ihre Teile kombinieren.'; 'Da twonly ohne zentrale Benutzerkonten oder Telefonnummern funktioniert, um deine Privatsphäre zu maximieren, setzen wir auf einen dezentralen Wiederherstellungsmechanismus. Mit Shamir\'s Secret Sharing wird deine kryptografische Identität in unabhängige Teile aufgeteilt und an deine Freunde verteilt. Um den Zugriff wiederherzustellen, muss ein vorher festgelegter Schwellenwert dieser Freunde ihre Teile kombinieren.';
@override @override
String get passwordlessRecoveryInfoWhySecondFactor => String get passwordlessRecoveryInfoWhySecondFactor =>
@ -2233,7 +2232,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get passwordlessRecoveryInfoWhySecondFactorDesc => String get passwordlessRecoveryInfoWhySecondFactorDesc =>
'Der zweite Faktor (E-Mail oder PIN) dient als wichtiger kryptografischer Schutz vor bösartiger Absprache. Wenn sich deine vertrauenswürdigen Freunde hinter deinem Rücken absprechen würden, könnten sie deine Identität ohne den Schlüssel für den zweiten Faktor trotzdem nicht entschlüsseln.'; 'Der zweite Faktor (E-Mail oder PIN) dient als wichtiger kryptografischer Schutz vor bösartiger Absprache. Wenn sich deine Freunde hinter deinem Rücken absprechen würden, könnten sie deine Identität ohne den Schlüssel für den zweiten Faktor trotzdem nicht entschlüsseln.';
@override @override
String get passwordlessRecoveryInfoGotIt => 'Verstanden'; String get passwordlessRecoveryInfoGotIt => 'Verstanden';
@ -2258,12 +2257,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String passwordlessRecoveryThresholdDesc(num count) { String passwordlessRecoveryThresholdDesc(num count) {
return 'Um dein Konto wiederherzustellen, brauchst du $count deiner ausgewählten vertrauenswürdigen Freunde.'; return 'Um dein Konto wiederherzustellen, brauchst du $count deiner ausgewählten Freunde.';
} }
@override @override
String get passwordlessRecoveryThresholdTitle => String get passwordlessRecoveryThresholdTitle =>
'Benötigte vertrauenswürdige Freunde für das Backup'; 'Benötigte Freunde für das Backup';
@override @override
String passwordlessRecoverySelectFriendsNeeded(num count) { String passwordlessRecoverySelectFriendsNeeded(num count) {
@ -2271,12 +2270,11 @@ class AppLocalizationsDe extends AppLocalizations {
} }
@override @override
String get passwordlessRecoverySelectFriends => String get passwordlessRecoverySelectFriends => 'Freunde auswählen';
'Vertrauenswürdige Freunde auswählen';
@override @override
String get passwordlessRecoveryNoFriendsSelected => String get passwordlessRecoveryNoFriendsSelected =>
'Noch keine vertrauenswürdigen Freunde ausgewählt'; 'Noch keine Freunde ausgewählt';
@override @override
String get passwordlessRecoverySubtitle => String get passwordlessRecoverySubtitle =>

@ -1 +1 @@
Subproject commit efb09c059d5885ec58e78189988ecbb52f00628b Subproject commit 4d8e1373bf3fea617157a86791be7c65edd879d3

View file

@ -225,11 +225,11 @@ class PasswordLessRecovery {
// Stored not on the server, so the server is unable to link a email to a user until the actuall recovery or can // 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 // brute-force the pin
List<int>? encryptedServerKeyNonce; List<int>? encryptedServerKeyNonce;
List<int>? encryptedServerKeyMac;
// ---> // --->
// Checking with the server that the server data is valid and not delted throug the pin protection for example. // Checking with the server that the server data is valid and not delted throug the pin protection for example.
DateTime? lastServerHeartbeat; DateTime? lastServerHeartbeat;
DateTime? lastContactHeartbeat;
List<int>? encryptedServerKey; List<int>? encryptedServerKey;
Map<String, dynamic> toJson() => _$PasswordLessRecoveryToJson(this); Map<String, dynamic> toJson() => _$PasswordLessRecoveryToJson(this);

View file

@ -255,12 +255,12 @@ PasswordLessRecovery _$PasswordLessRecoveryFromJson(
(json['encryptedServerKeyNonce'] as List<dynamic>?) (json['encryptedServerKeyNonce'] as List<dynamic>?)
?.map((e) => (e as num).toInt()) ?.map((e) => (e as num).toInt())
.toList() .toList()
..encryptedServerKeyMac = (json['encryptedServerKeyMac'] as List<dynamic>?)
?.map((e) => (e as num).toInt())
.toList()
..lastServerHeartbeat = json['lastServerHeartbeat'] == null ..lastServerHeartbeat = json['lastServerHeartbeat'] == null
? null ? null
: DateTime.parse(json['lastServerHeartbeat'] as String) : DateTime.parse(json['lastServerHeartbeat'] as String)
..lastContactHeartbeat = json['lastContactHeartbeat'] == null
? null
: DateTime.parse(json['lastContactHeartbeat'] as String)
..encryptedServerKey = (json['encryptedServerKey'] as List<dynamic>?) ..encryptedServerKey = (json['encryptedServerKey'] as List<dynamic>?)
?.map((e) => (e as num).toInt()) ?.map((e) => (e as num).toInt())
.toList(); .toList();
@ -272,7 +272,7 @@ Map<String, dynamic> _$PasswordLessRecoveryToJson(
'pinSeed': instance.pinSeed, 'pinSeed': instance.pinSeed,
'pinUnlockToken': instance.pinUnlockToken, 'pinUnlockToken': instance.pinUnlockToken,
'encryptedServerKeyNonce': instance.encryptedServerKeyNonce, 'encryptedServerKeyNonce': instance.encryptedServerKeyNonce,
'encryptedServerKeyMac': instance.encryptedServerKeyMac,
'lastServerHeartbeat': instance.lastServerHeartbeat?.toIso8601String(), 'lastServerHeartbeat': instance.lastServerHeartbeat?.toIso8601String(),
'lastContactHeartbeat': instance.lastContactHeartbeat?.toIso8601String(),
'encryptedServerKey': instance.encryptedServerKey, 'encryptedServerKey': instance.encryptedServerKey,
}; };

View file

@ -435,7 +435,6 @@ class SharedSecretData extends $pb.GeneratedMessage {
$core.List<$core.int>? pinUnlockToken, $core.List<$core.int>? pinUnlockToken,
$core.String? emailHint, $core.String? emailHint,
$core.List<$core.int>? encryptedServerKeyNonce, $core.List<$core.int>? encryptedServerKeyNonce,
$core.List<$core.int>? encryptedServerKeyMac,
}) { }) {
final result = create(); final result = create();
if (recoveryData != null) result.recoveryData = recoveryData; if (recoveryData != null) result.recoveryData = recoveryData;
@ -444,8 +443,6 @@ class SharedSecretData extends $pb.GeneratedMessage {
if (emailHint != null) result.emailHint = emailHint; if (emailHint != null) result.emailHint = emailHint;
if (encryptedServerKeyNonce != null) if (encryptedServerKeyNonce != null)
result.encryptedServerKeyNonce = encryptedServerKeyNonce; result.encryptedServerKeyNonce = encryptedServerKeyNonce;
if (encryptedServerKeyMac != null)
result.encryptedServerKeyMac = encryptedServerKeyMac;
return result; return result;
} }
@ -472,8 +469,6 @@ class SharedSecretData extends $pb.GeneratedMessage {
..aOS(4, _omitFieldNames ? '' : 'emailHint') ..aOS(4, _omitFieldNames ? '' : 'emailHint')
..a<$core.List<$core.int>>( ..a<$core.List<$core.int>>(
5, _omitFieldNames ? '' : 'encryptedServerKeyNonce', $pb.PbFieldType.OY) 5, _omitFieldNames ? '' : 'encryptedServerKeyNonce', $pb.PbFieldType.OY)
..a<$core.List<$core.int>>(
6, _omitFieldNames ? '' : 'encryptedServerKeyMac', $pb.PbFieldType.OY)
..hasRequiredFields = false; ..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
@ -541,16 +536,6 @@ class SharedSecretData extends $pb.GeneratedMessage {
$core.bool hasEncryptedServerKeyNonce() => $_has(4); $core.bool hasEncryptedServerKeyNonce() => $_has(4);
@$pb.TagNumber(5) @$pb.TagNumber(5)
void clearEncryptedServerKeyNonce() => $_clearField(5); void clearEncryptedServerKeyNonce() => $_clearField(5);
@$pb.TagNumber(6)
$core.List<$core.int> get encryptedServerKeyMac => $_getN(5);
@$pb.TagNumber(6)
set encryptedServerKeyMac($core.List<$core.int> value) =>
$_setBytes(5, value);
@$pb.TagNumber(6)
$core.bool hasEncryptedServerKeyMac() => $_has(5);
@$pb.TagNumber(6)
void clearEncryptedServerKeyMac() => $_clearField(6);
} }
const $core.bool _omitFieldNames = const $core.bool _omitFieldNames =

View file

@ -151,22 +151,12 @@ const SharedSecretData$json = {
'10': 'encryptedServerKeyNonce', '10': 'encryptedServerKeyNonce',
'17': true '17': true
}, },
{
'1': 'encrypted_server_key_mac',
'3': 6,
'4': 1,
'5': 12,
'9': 4,
'10': 'encryptedServerKeyMac',
'17': true
},
], ],
'8': [ '8': [
{'1': '_pin_seed'}, {'1': '_pin_seed'},
{'1': '_pin_unlock_token'}, {'1': '_pin_unlock_token'},
{'1': '_email_hint'}, {'1': '_email_hint'},
{'1': '_encrypted_server_key_nonce'}, {'1': '_encrypted_server_key_nonce'},
{'1': '_encrypted_server_key_mac'},
], ],
}; };
@ -176,7 +166,5 @@ final $typed_data.Uint8List sharedSecretDataDescriptor = $convert.base64Decode(
'IeCghwaW5fc2VlZBgCIAEoDEgAUgdwaW5TZWVkiAEBEi0KEHBpbl91bmxvY2tfdG9rZW4YAyAB' 'IeCghwaW5fc2VlZBgCIAEoDEgAUgdwaW5TZWVkiAEBEi0KEHBpbl91bmxvY2tfdG9rZW4YAyAB'
'KAxIAVIOcGluVW5sb2NrVG9rZW6IAQESIgoKZW1haWxfaGludBgEIAEoCUgCUgllbWFpbEhpbn' 'KAxIAVIOcGluVW5sb2NrVG9rZW6IAQESIgoKZW1haWxfaGludBgEIAEoCUgCUgllbWFpbEhpbn'
'SIAQESQAoaZW5jcnlwdGVkX3NlcnZlcl9rZXlfbm9uY2UYBSABKAxIA1IXZW5jcnlwdGVkU2Vy' 'SIAQESQAoaZW5jcnlwdGVkX3NlcnZlcl9rZXlfbm9uY2UYBSABKAxIA1IXZW5jcnlwdGVkU2Vy'
'dmVyS2V5Tm9uY2WIAQESPAoYZW5jcnlwdGVkX3NlcnZlcl9rZXlfbWFjGAYgASgMSARSFWVuY3' 'dmVyS2V5Tm9uY2WIAQFCCwoJX3Bpbl9zZWVkQhMKEV9waW5fdW5sb2NrX3Rva2VuQg0KC19lbW'
'J5cHRlZFNlcnZlcktleU1hY4gBAUILCglfcGluX3NlZWRCEwoRX3Bpbl91bmxvY2tfdG9rZW5C' 'FpbF9oaW50Qh0KG19lbmNyeXB0ZWRfc2VydmVyX2tleV9ub25jZQ==');
'DQoLX2VtYWlsX2hpbnRCHQobX2VuY3J5cHRlZF9zZXJ2ZXJfa2V5X25vbmNlQhsKGV9lbmNyeX'
'B0ZWRfc2VydmVyX2tleV9tYWM=');

View file

@ -63,7 +63,6 @@ message SharedSecretData {
optional string email_hint = 4; optional string email_hint = 4;
optional bytes encrypted_server_key_nonce = 5; optional bytes encrypted_server_key_nonce = 5;
optional bytes encrypted_server_key_mac = 6;
} }

View file

@ -4,10 +4,8 @@ import 'dart:convert' show utf8;
import 'package:clock/clock.dart'; import 'package:clock/clock.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:crypto/crypto.dart' hide Hmac; import 'package:crypto/crypto.dart' hide Hmac;
import 'package:cryptography_flutter_plus/cryptography_flutter_plus.dart'
show FlutterChacha20;
import 'package:cryptography_plus/cryptography_plus.dart' import 'package:cryptography_plus/cryptography_plus.dart'
show Hmac, Mac, SecretBox, SecretKey; show Hmac, Mac, SecretBox, SecretKey, Xchacha20;
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:fixnum/fixnum.dart'; import 'package:fixnum/fixnum.dart';
import 'package:twonly/core/bridge/wrapper.dart'; import 'package:twonly/core/bridge/wrapper.dart';
@ -61,7 +59,7 @@ class PasswordlessRecoveryService {
await UserService.update((u) => u.passwordLessRecovery = null); await UserService.update((u) => u.passwordLessRecovery = null);
final config = PasswordLessRecovery(); final config = PasswordLessRecovery();
final chacha20 = FlutterChacha20.poly1305Aead(); final xchacha20 = Xchacha20.poly1305Aead();
// 2. If enabled, handle the second factor and create serverKey // 2. If enabled, handle the second factor and create serverKey
@ -99,7 +97,10 @@ class PasswordlessRecoveryService {
// Brute-force protection for the user's pin: // Brute-force protection for the user's pin:
// - Server: Does not know the seed. // - Server: Does not know the seed.
// - Trusted friends: Can only check the result X times before the server deletes the key. // - Trusted friends:
// Can only check the result X times before the server deletes the key. As they do not have
// the mac and the cypher text they are unable to brute-force the pin localy. And the server only allows 10
// tries.
final pinProtectionKey = await Hmac.sha256().calculateMac( final pinProtectionKey = await Hmac.sha256().calculateMac(
Uint8List.fromList(utf8.encode(secondFactorValue)), Uint8List.fromList(utf8.encode(secondFactorValue)),
secretKey: SecretKey(config.pinSeed!), secretKey: SecretKey(config.pinSeed!),
@ -118,21 +119,24 @@ class PasswordlessRecoveryService {
// colaberate, they additional need the serverKey to decrypt the user's key. // colaberate, they additional need the serverKey to decrypt the user's key.
serverKey = getRandomUint8List(32); serverKey = getRandomUint8List(32);
final secretBox = await chacha20.encrypt( final secretBox = await xchacha20.encrypt(
serverKey, serverKey,
secretKey: secondFactorEncryptedServerKeyKey, secretKey: secondFactorEncryptedServerKeyKey,
nonce: chacha20.newNonce(), nonce: xchacha20.newNonce(),
); );
// This is send to the server and then deleted localy // The server only gets the encrypted server key and the mac. Because the server does not know the nonce (192-bit
encryptedServerKey = Uint8List.fromList(secretBox.cipherText); // 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 users orginal 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.
encryptedServerKey = Uint8List.fromList([
...secretBox.cipherText,
...secretBox.mac.bytes,
]);
// Because the server does not know the nonce and the mac, he is unable to link a email to the user and also
// unable to brute-force the pin.
config config
..encryptedServerKeyNonce = secretBox.nonce ..encryptedServerKeyNonce = secretBox.nonce
..encryptedServerKey = encryptedServerKey ..encryptedServerKey = encryptedServerKey;
..encryptedServerKeyMac = secretBox.mac.bytes;
} }
// 3. Using shamir's secret to generate the shares for the users. // 3. Using shamir's secret to generate the shares for the users.
@ -147,10 +151,10 @@ class PasswordlessRecoveryService {
if (serverKey != null) { if (serverKey != null) {
// Second factor was enabled, so encrypt the recoveryData using the serverKey. // Second factor was enabled, so encrypt the recoveryData using the serverKey.
final secretBox = await chacha20.encrypt( final secretBox = await xchacha20.encrypt(
recoveryData, recoveryData,
secretKey: SecretKey(serverKey), secretKey: SecretKey(serverKey),
nonce: chacha20.newNonce(), nonce: xchacha20.newNonce(),
); );
recoveryData = EncryptedEnvelope( recoveryData = EncryptedEnvelope(
@ -165,7 +169,6 @@ class PasswordlessRecoveryService {
pinSeed: config.pinSeed, pinSeed: config.pinSeed,
pinUnlockToken: config.pinUnlockToken, pinUnlockToken: config.pinUnlockToken,
emailHint: emailHint, emailHint: emailHint,
encryptedServerKeyMac: config.encryptedServerKeyMac,
encryptedServerKeyNonce: config.encryptedServerKeyNonce, encryptedServerKeyNonce: config.encryptedServerKeyNonce,
).writeToBuffer(); ).writeToBuffer();
@ -224,15 +227,19 @@ class PasswordlessRecoveryService {
pinProtectionKey.bytes, pinProtectionKey.bytes,
); );
final chacha20 = FlutterChacha20.poly1305Aead(); final xchacha20 = Xchacha20.poly1305Aead();
final combined = config.encryptedServerKey!;
final cipherText = combined.sublist(0, combined.length - 16);
final macBytes = combined.sublist(combined.length - 16);
final secretBox = SecretBox( final secretBox = SecretBox(
config.encryptedServerKey!, cipherText,
nonce: config.encryptedServerKeyNonce!, nonce: config.encryptedServerKeyNonce!,
mac: Mac(config.encryptedServerKeyMac!), mac: Mac(macBytes),
); );
await chacha20.decrypt( await xchacha20.decrypt(
secretBox, secretBox,
secretKey: secondFactorEncryptedServerKeyKey, secretKey: secondFactorEncryptedServerKeyKey,
); );
@ -251,7 +258,7 @@ class PasswordlessRecoveryService {
final lastHeartbeat = config.lastServerHeartbeat; final lastHeartbeat = config.lastServerHeartbeat;
final isOlderThanAMonth = final isOlderThanAMonth =
lastHeartbeat != null && lastHeartbeat != null &&
clock.now().difference(lastHeartbeat).inDays > 30; clock.now().difference(lastHeartbeat).inDays > 20;
if ((lastHeartbeat == null || isOlderThanAMonth) && if ((lastHeartbeat == null || isOlderThanAMonth) &&
config.encryptedServerKey != null) { config.encryptedServerKey != null) {
@ -267,39 +274,43 @@ class PasswordlessRecoveryService {
} }
} }
// Get all contacts where recoveryLastHeartbeat is NULL. Then for each contacts send final lastContactHeartbeat = config.lastContactHeartbeat;
final pendingShares = final isContactHeartbeatOlderThan24h =
await (twonlyDB.select(twonlyDB.contacts)..where( lastContactHeartbeat == null ||
(t) => clock.now().difference(lastContactHeartbeat).inHours >= 24;
t.recoveryIsTrustedFriend.equals(true) &
t.recoveryLastHeartbeat.isNull() &
t.recoverySecretShare.isNotNull(),
))
.get();
for (final contact in pendingShares) { if (isContactHeartbeatOlderThan24h) {
try { // Get all contacts where recoveryLastHeartbeat is NULL. Then for each contacts send
await sendCipherText( final pendingShares =
contact.userId, await (twonlyDB.select(twonlyDB.contacts)..where(
pb.EncryptedContent( (t) =>
passwordlessRecovery: pb.EncryptedContent_PasswordLessRecovery( t.recoveryIsTrustedFriend.equals(true) &
recoverySecretShare: contact.recoverySecretShare, t.recoveryLastHeartbeat.isNull() &
delete: false, t.recoverySecretShare.isNotNull(),
))
.get();
for (final contact in pendingShares) {
try {
await sendCipherText(
contact.userId,
pb.EncryptedContent(
passwordlessRecovery: pb.EncryptedContent_PasswordLessRecovery(
recoverySecretShare: contact.recoverySecretShare,
delete: false,
),
), ),
), );
); } catch (e) {
Log.error(
await twonlyDB.contactsDao.updateContact( 'Failed to send PasswordLessRecovery share to contact ${contact.userId}: $e',
contact.userId, );
ContactsCompanion( }
recoveryLastHeartbeat: Value(clock.now()),
),
);
} catch (e) {
Log.error(
'Failed to send PasswordLessRecovery share to contact ${contact.userId}: $e',
);
} }
await UserService.update((u) {
u.passwordLessRecovery?.lastContactHeartbeat = clock.now();
});
} }
} }