fix comments and show shared contacts avatar
Some checks failed
Flutter analyze & test / flutter_analyze_and_test (push) Has been cancelled

This commit is contained in:
otsmr 2026-07-16 10:31:58 +02:00
parent 1b37268577
commit df9b0d66bd
7 changed files with 33 additions and 23 deletions

View file

@ -14,6 +14,7 @@ class ReceivedRecoveryShare {
required this.myAvatarSvg, required this.myAvatarSvg,
required this.threshold, required this.threshold,
required this.sharedSecretDataBytes, required this.sharedSecretDataBytes,
this.trustedFriendAvatarSvg,
}); });
factory ReceivedRecoveryShare.fromJson(Map<String, dynamic> json) => factory ReceivedRecoveryShare.fromJson(Map<String, dynamic> json) =>
@ -22,6 +23,7 @@ class ReceivedRecoveryShare {
final int messageId; final int messageId;
final String trustedFriendDisplayName; final String trustedFriendDisplayName;
final List<int>? trustedFriendAvatarSvg;
final String myDisplayName; final String myDisplayName;
final int myUserId; final int myUserId;
@ -58,7 +60,6 @@ class OnboardingState {
List<int>? encryptionKey; List<int>? encryptionKey;
bool emailRecoveryRequested; bool emailRecoveryRequested;
List<ReceivedRecoveryShare> receivedShares = []; List<ReceivedRecoveryShare> receivedShares = [];
Map<String, dynamic> toJson() => _$OnboardingStateToJson(this); Map<String, dynamic> toJson() => _$OnboardingStateToJson(this);

View file

@ -11,6 +11,9 @@ ReceivedRecoveryShare _$ReceivedRecoveryShareFromJson(
) => ReceivedRecoveryShare( ) => ReceivedRecoveryShare(
messageId: (json['messageId'] as num).toInt(), messageId: (json['messageId'] as num).toInt(),
trustedFriendDisplayName: json['trustedFriendDisplayName'] as String, trustedFriendDisplayName: json['trustedFriendDisplayName'] as String,
trustedFriendAvatarSvg: (json['trustedFriendAvatarSvg'] as List<dynamic>?)
?.map((e) => (e as num).toInt())
.toList(),
myDisplayName: json['myDisplayName'] as String, myDisplayName: json['myDisplayName'] as String,
myUserId: (json['myUserId'] as num).toInt(), myUserId: (json['myUserId'] as num).toInt(),
myAvatarSvg: (json['myAvatarSvg'] as List<dynamic>?) myAvatarSvg: (json['myAvatarSvg'] as List<dynamic>?)
@ -27,6 +30,7 @@ Map<String, dynamic> _$ReceivedRecoveryShareToJson(
) => <String, dynamic>{ ) => <String, dynamic>{
'messageId': instance.messageId, 'messageId': instance.messageId,
'trustedFriendDisplayName': instance.trustedFriendDisplayName, 'trustedFriendDisplayName': instance.trustedFriendDisplayName,
'trustedFriendAvatarSvg': instance.trustedFriendAvatarSvg,
'myDisplayName': instance.myDisplayName, 'myDisplayName': instance.myDisplayName,
'myUserId': instance.myUserId, 'myUserId': instance.myUserId,
'myAvatarSvg': instance.myAvatarSvg, 'myAvatarSvg': instance.myAvatarSvg,

View file

@ -247,7 +247,7 @@ class BackupService {
} }
if (recoveryData.state == BackupRecoveryState.archiveBackupStarted) { if (recoveryData.state == BackupRecoveryState.archiveBackupStarted) {
// The KeyManager was restored sucessfully, restore the archive now. // The KeyManager was restored successfully, restore the archive now.
try { try {
final downloadToken = final downloadToken =
await RustBackupArchive.getBackupDownloadToken(); await RustBackupArchive.getBackupDownloadToken();

View file

@ -187,36 +187,29 @@ class PasswordlessRecoveryService {
switch (secondFactorType) { switch (secondFactorType) {
case SecondFactorType.email: case SecondFactorType.email:
// The serverKey is encrypted with the email; this protects the server from seeing the user's email address, while
// also ensuring that the server can only send the real secret to the user's configured email, as a different
// email will result in a different secret key.
// This is only stored so the user can see there email, and verify that he has set the valid mail...
config.email = secondFactorValue; config.email = secondFactorValue;
emailHint = createEmailHint(secondFactorValue); emailHint = createEmailHint(secondFactorValue);
// E-Mail Protection: // E-Mail Protection:
// - Server can only learn the email during recovery. Ensured as the server gets the NONCE and MAC to decrpyt during recovery. // - 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 send to the mail, they whould need access to the user's mail account. // - Trusted-friends: Server key is only sent to the mail, they would need access to the user's mail account.
secondFactorEncryptedServerKeyKey = SecretKey( secondFactorEncryptedServerKeyKey = SecretKey(
Uint8List.fromList(sha256.convert(utf8.encode(config.email!)).bytes), Uint8List.fromList(sha256.convert(utf8.encode(config.email!)).bytes),
); );
case SecondFactorType.pin: case SecondFactorType.pin:
// The pin seed - never shared with the server - ensures that the server is unable to brute-force real user's 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.pinSeed = getRandomUint8List(32);
// As the pin is heavily protected against brute-forcing e.g. will be deleted by the server after X tries, the // 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 malicous user (except the trusted friends) to triger this deletion. // unlock token is required to prevent a malicious user (except the trusted friends) to trigger this deletion.
config.pinUnlockToken = getRandomUint8List(32); config.pinUnlockToken = getRandomUint8List(32);
// 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: // - Trusted friends: Can only check the result 10 times before the server deletes the key. As they do not have
// Can only check the result X 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.
// 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!),
@ -224,7 +217,7 @@ class PasswordlessRecoveryService {
// To restore the user has to provide the server with this encryption key. The server then can verify the // 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 // correct pin was entered, when decrypting the server key as he also receives the mac and nonce from the user
// during recovery. Only when the mac is correct the server provides the user with the serverKey. // Only when the mac is correct the server provides the user with the serverKey.
secondFactorEncryptedServerKeyKey = SecretKey(pinProtectionKey.bytes); secondFactorEncryptedServerKeyKey = SecretKey(pinProtectionKey.bytes);
case SecondFactorType.none: case SecondFactorType.none:
@ -232,7 +225,7 @@ class PasswordlessRecoveryService {
if (secondFactorEncryptedServerKeyKey != null) { if (secondFactorEncryptedServerKeyKey != null) {
// The server key is used to encrypt the RecoveryData of the users. This ensures that when the trusted friends // The server key is used to encrypt the RecoveryData of the users. This ensures that when the trusted friends
// colaberate, they additional need the serverKey to decrypt the user's key. // collaborate, they additionally need the serverKey to decrypt the user's key.
serverKey = getRandomUint8List(32); serverKey = getRandomUint8List(32);
final secretBox = await xchacha20.encrypt( final secretBox = await xchacha20.encrypt(
@ -243,7 +236,7 @@ class PasswordlessRecoveryService {
// The server only gets the encrypted server key and the mac. Because the server does not know the nonce (192-bit // 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 // 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 // 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. // brute-force the email of the user as he does not have the nonce.
encryptedServerKey = Uint8List.fromList([ encryptedServerKey = Uint8List.fromList([
...secretBox.cipherText, ...secretBox.cipherText,
@ -323,7 +316,7 @@ class PasswordlessRecoveryService {
unawaited(performHeartbeat()); unawaited(performHeartbeat());
// The passwordless is configured sucessfully. // The passwordless is configured successfully.
return true; return true;
} }
@ -396,7 +389,7 @@ class PasswordlessRecoveryService {
clock.now().difference(lastContactHeartbeat).inHours >= 24; clock.now().difference(lastContactHeartbeat).inHours >= 24;
if (isContactHeartbeatOlderThan24h) { if (isContactHeartbeatOlderThan24h) {
// Get all contacts where recoveryLastHeartbeat is NULL. Then for each contacts send. // Get all contacts where recoveryLastHeartbeat is NULL. Then for each contact send.
// recoveryLastHeartbeat is ONLY updated in case the contact has responded. // recoveryLastHeartbeat is ONLY updated in case the contact has responded.
final pendingShares = final pendingShares =
await (twonlyDB.select(twonlyDB.contacts)..where( await (twonlyDB.select(twonlyDB.contacts)..where(
@ -542,7 +535,7 @@ class PasswordlessRecoveryService {
final recoveryLastHeartbeat = final recoveryLastHeartbeat =
const ListEquality().equals(computedHash, msg.hash) const ListEquality().equals(computedHash, msg.hash)
? clock.now() ? clock.now()
: null; // The stored share not valid (maybe a old backup was restored). This will cause the performHeartbeat to resend him his share : null; // The stored share not valid (maybe an old backup was restored). This will cause the performHeartbeat to resend him his share
Log.info( Log.info(
'[$receiptId] Got heartbeat: ($recoveryLastHeartbeat)', '[$receiptId] Got heartbeat: ($recoveryLastHeartbeat)',
); );
@ -602,6 +595,9 @@ class PasswordlessRecoveryService {
final receivedShare = ReceivedRecoveryShare( final receivedShare = ReceivedRecoveryShare(
messageId: msgId, messageId: msgId,
trustedFriendDisplayName: share.trustedFriend.displayName, trustedFriendDisplayName: share.trustedFriend.displayName,
trustedFriendAvatarSvg: share.trustedFriend.hasAvatar()
? share.trustedFriend.avatar
: null,
myDisplayName: share.shareUser.displayName, myDisplayName: share.shareUser.displayName,
myUserId: share.shareUser.userId.toInt(), myUserId: share.shareUser.userId.toInt(),
myAvatarSvg: share.shareUser.hasAvatar() myAvatarSvg: share.shareUser.hasAvatar()

View file

@ -1,7 +1,10 @@
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/utils/avatars.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
import 'package:twonly/src/visual/components/verification_badge.comp.dart'; import 'package:twonly/src/visual/components/verification_badge.comp.dart';
@ -12,6 +15,7 @@ class ContactChip extends StatelessWidget {
const ContactChip({ const ContactChip({
this.contact, this.contact,
this.username, this.username,
this.avatarSvg,
this.onTap, this.onTap,
super.key, super.key,
}) : assert( }) : assert(
@ -21,6 +25,7 @@ class ContactChip extends StatelessWidget {
final Contact? contact; final Contact? contact;
final String? username; final String? username;
final List<int>? avatarSvg;
final void Function(int)? onTap; final void Function(int)? onTap;
@override @override
@ -43,6 +48,9 @@ class ContactChip extends StatelessWidget {
avatar: AvatarIcon( avatar: AvatarIcon(
contactId: contact?.userId, contactId: contact?.userId,
fontSize: 10, fontSize: 10,
svg: avatarSvg != null
? getAvatarSvg(Uint8List.fromList(avatarSvg!))
: null,
), ),
label: Row( label: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,

View file

@ -364,7 +364,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
return advanceToNextMediaOrExit(); return advanceToNextMediaOrExit();
} }
// The server can now delete the encrypted bytes, as the users has sucessfully opened it. // The server can now delete the encrypted bytes, as the user has successfully opened it.
Log.info( Log.info(
'Calling downloadDone for media ID: ${currentMediaLocal.mediaFile.mediaId}', 'Calling downloadDone for media ID: ${currentMediaLocal.mediaFile.mediaId}',
); );

View file

@ -543,6 +543,7 @@ class _RecoverPasswordlessState extends State<RecoverPasswordless> {
children: shares.map((share) { children: shares.map((share) {
return ContactChip( return ContactChip(
username: share.trustedFriendDisplayName, username: share.trustedFriendDisplayName,
avatarSvg: share.trustedFriendAvatarSvg,
); );
}).toList(), }).toList(),
), ),