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';
@override
String get passwordlessRecoveryNoFriendsFound =>
'Keine vertrauenswürdigen Freunde gefunden.';
String get passwordlessRecoveryNoFriendsFound => 'Keine Freunde gefunden.';
@override
String get passwordlessRecoveryActiveFriends => 'Aktive Freunde';
@override
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
String get passwordlessRecoveryInactiveFriends => 'Inaktive Freunde';
@ -2193,7 +2192,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get passwordlessRecoveryNeedAtLeast3 =>
'Du brauchst mindestens 3 vertrauenswürdige Freunde.';
'Du brauchst mindestens 3 Freunde.';
@override
String get passwordlessRecoveryInvalidPin => 'Ungültige PIN';
@ -2217,7 +2216,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String passwordlessRecoveryStatusEnabled(num count) {
return 'Aktiviert • $count vertrauenswürdige Freunde';
return 'Aktiviert • $count Freunde';
}
@override
@ -2225,7 +2224,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
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
String get passwordlessRecoveryInfoWhySecondFactor =>
@ -2233,7 +2232,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
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
String get passwordlessRecoveryInfoGotIt => 'Verstanden';
@ -2258,12 +2257,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override
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
String get passwordlessRecoveryThresholdTitle =>
'Benötigte vertrauenswürdige Freunde für das Backup';
'Benötigte Freunde für das Backup';
@override
String passwordlessRecoverySelectFriendsNeeded(num count) {
@ -2271,12 +2270,11 @@ class AppLocalizationsDe extends AppLocalizations {
}
@override
String get passwordlessRecoverySelectFriends =>
'Vertrauenswürdige Freunde auswählen';
String get passwordlessRecoverySelectFriends => 'Freunde auswählen';
@override
String get passwordlessRecoveryNoFriendsSelected =>
'Noch keine vertrauenswürdigen Freunde ausgewählt';
'Noch keine Freunde ausgewählt';
@override
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
// brute-force the pin
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.
DateTime? lastServerHeartbeat;
DateTime? lastContactHeartbeat;
List<int>? encryptedServerKey;
Map<String, dynamic> toJson() => _$PasswordLessRecoveryToJson(this);

View file

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

View file

@ -435,7 +435,6 @@ class SharedSecretData extends $pb.GeneratedMessage {
$core.List<$core.int>? pinUnlockToken,
$core.String? emailHint,
$core.List<$core.int>? encryptedServerKeyNonce,
$core.List<$core.int>? encryptedServerKeyMac,
}) {
final result = create();
if (recoveryData != null) result.recoveryData = recoveryData;
@ -444,8 +443,6 @@ class SharedSecretData extends $pb.GeneratedMessage {
if (emailHint != null) result.emailHint = emailHint;
if (encryptedServerKeyNonce != null)
result.encryptedServerKeyNonce = encryptedServerKeyNonce;
if (encryptedServerKeyMac != null)
result.encryptedServerKeyMac = encryptedServerKeyMac;
return result;
}
@ -472,8 +469,6 @@ class SharedSecretData extends $pb.GeneratedMessage {
..aOS(4, _omitFieldNames ? '' : 'emailHint')
..a<$core.List<$core.int>>(
5, _omitFieldNames ? '' : 'encryptedServerKeyNonce', $pb.PbFieldType.OY)
..a<$core.List<$core.int>>(
6, _omitFieldNames ? '' : 'encryptedServerKeyMac', $pb.PbFieldType.OY)
..hasRequiredFields = false;
@$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);
@$pb.TagNumber(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 =

View file

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

View file

@ -63,7 +63,6 @@ message SharedSecretData {
optional string email_hint = 4;
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:collection/collection.dart';
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'
show Hmac, Mac, SecretBox, SecretKey;
show Hmac, Mac, SecretBox, SecretKey, Xchacha20;
import 'package:drift/drift.dart';
import 'package:fixnum/fixnum.dart';
import 'package:twonly/core/bridge/wrapper.dart';
@ -61,7 +59,7 @@ class PasswordlessRecoveryService {
await UserService.update((u) => u.passwordLessRecovery = null);
final config = PasswordLessRecovery();
final chacha20 = FlutterChacha20.poly1305Aead();
final xchacha20 = Xchacha20.poly1305Aead();
// 2. If enabled, handle the second factor and create serverKey
@ -99,7 +97,10 @@ class PasswordlessRecoveryService {
// Brute-force protection for the user's pin:
// - 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(
Uint8List.fromList(utf8.encode(secondFactorValue)),
secretKey: SecretKey(config.pinSeed!),
@ -118,21 +119,24 @@ class PasswordlessRecoveryService {
// colaberate, they additional need the serverKey to decrypt the user's key.
serverKey = getRandomUint8List(32);
final secretBox = await chacha20.encrypt(
final secretBox = await xchacha20.encrypt(
serverKey,
secretKey: secondFactorEncryptedServerKeyKey,
nonce: chacha20.newNonce(),
nonce: xchacha20.newNonce(),
);
// This is send to the server and then deleted localy
encryptedServerKey = Uint8List.fromList(secretBox.cipherText);
// 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 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
..encryptedServerKeyNonce = secretBox.nonce
..encryptedServerKey = encryptedServerKey
..encryptedServerKeyMac = secretBox.mac.bytes;
..encryptedServerKey = encryptedServerKey;
}
// 3. Using shamir's secret to generate the shares for the users.
@ -147,10 +151,10 @@ class PasswordlessRecoveryService {
if (serverKey != null) {
// Second factor was enabled, so encrypt the recoveryData using the serverKey.
final secretBox = await chacha20.encrypt(
final secretBox = await xchacha20.encrypt(
recoveryData,
secretKey: SecretKey(serverKey),
nonce: chacha20.newNonce(),
nonce: xchacha20.newNonce(),
);
recoveryData = EncryptedEnvelope(
@ -165,7 +169,6 @@ class PasswordlessRecoveryService {
pinSeed: config.pinSeed,
pinUnlockToken: config.pinUnlockToken,
emailHint: emailHint,
encryptedServerKeyMac: config.encryptedServerKeyMac,
encryptedServerKeyNonce: config.encryptedServerKeyNonce,
).writeToBuffer();
@ -224,15 +227,19 @@ class PasswordlessRecoveryService {
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(
config.encryptedServerKey!,
cipherText,
nonce: config.encryptedServerKeyNonce!,
mac: Mac(config.encryptedServerKeyMac!),
mac: Mac(macBytes),
);
await chacha20.decrypt(
await xchacha20.decrypt(
secretBox,
secretKey: secondFactorEncryptedServerKeyKey,
);
@ -251,7 +258,7 @@ class PasswordlessRecoveryService {
final lastHeartbeat = config.lastServerHeartbeat;
final isOlderThanAMonth =
lastHeartbeat != null &&
clock.now().difference(lastHeartbeat).inDays > 30;
clock.now().difference(lastHeartbeat).inDays > 20;
if ((lastHeartbeat == null || isOlderThanAMonth) &&
config.encryptedServerKey != null) {
@ -267,39 +274,43 @@ class PasswordlessRecoveryService {
}
}
// Get all contacts where recoveryLastHeartbeat is NULL. Then for each contacts send
final pendingShares =
await (twonlyDB.select(twonlyDB.contacts)..where(
(t) =>
t.recoveryIsTrustedFriend.equals(true) &
t.recoveryLastHeartbeat.isNull() &
t.recoverySecretShare.isNotNull(),
))
.get();
final lastContactHeartbeat = config.lastContactHeartbeat;
final isContactHeartbeatOlderThan24h =
lastContactHeartbeat == null ||
clock.now().difference(lastContactHeartbeat).inHours >= 24;
for (final contact in pendingShares) {
try {
await sendCipherText(
contact.userId,
pb.EncryptedContent(
passwordlessRecovery: pb.EncryptedContent_PasswordLessRecovery(
recoverySecretShare: contact.recoverySecretShare,
delete: false,
if (isContactHeartbeatOlderThan24h) {
// Get all contacts where recoveryLastHeartbeat is NULL. Then for each contacts send
final pendingShares =
await (twonlyDB.select(twonlyDB.contacts)..where(
(t) =>
t.recoveryIsTrustedFriend.equals(true) &
t.recoveryLastHeartbeat.isNull() &
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,
),
),
),
);
await twonlyDB.contactsDao.updateContact(
contact.userId,
ContactsCompanion(
recoveryLastHeartbeat: Value(clock.now()),
),
);
} catch (e) {
Log.error(
'Failed to send PasswordLessRecovery share to contact ${contact.userId}: $e',
);
);
} catch (e) {
Log.error(
'Failed to send PasswordLessRecovery share to contact ${contact.userId}: $e',
);
}
}
await UserService.update((u) {
u.passwordLessRecovery?.lastContactHeartbeat = clock.now();
});
}
}