Encrypted messaging app
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

414 lines
11 KiB

import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:mime/mime.dart';
import 'package:pointycastle/export.dart';
import 'package:sqflite/sqflite.dart';
import 'package:uuid/uuid.dart';
import '/database/models/messages.dart';
import '/database/models/text_messages.dart';
import '/database/models/conversation_users.dart';
import '/database/models/friends.dart';
import '/database/models/my_profile.dart';
import '/utils/encryption/aes_helper.dart';
import '/utils/encryption/crypto_utils.dart';
import '/utils/storage/database.dart';
import '/utils/strings.dart';
import '/utils/storage/write_file.dart';
Future<Conversation> createConversation(String title, List<Friend> friends, bool twoUser) async {
final db = await getDatabaseConnection();
MyProfile profile = await MyProfile.getProfile();
var uuid = const Uuid();
final String conversationId = uuid.v4();
Uint8List symmetricKey = AesHelper.deriveKey(generateRandomString(32));
Conversation conversation = Conversation(
id: conversationId,
userId: profile.id,
symmetricKey: base64.encode(symmetricKey),
admin: true,
name: title,
twoUser: twoUser,
status: ConversationStatus.pending,
isRead: true,
messageExpiryDefault: 'no_expiry',
adminAddMembers: true,
adminEditInfo: true,
adminSendMessages: false,
);
await db.insert(
'conversations',
conversation.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
await db.insert(
'conversation_users',
ConversationUser(
id: uuid.v4(),
userId: profile.id,
conversationId: conversationId,
username: profile.username,
associationKey: uuid.v4(),
publicKey: profile.publicKey!,
admin: true,
).toMap(),
conflictAlgorithm: ConflictAlgorithm.fail,
);
for (Friend friend in friends) {
await db.insert(
'conversation_users',
ConversationUser(
id: uuid.v4(),
userId: friend.friendId,
conversationId: conversationId,
username: friend.username,
associationKey: uuid.v4(),
publicKey: friend.publicKey,
admin: twoUser ? true : false,
).toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
if (twoUser) {
List<Map<String, dynamic>> maps = await db.query(
'conversation_users',
where: 'conversation_id = ? AND user_id != ?',
whereArgs: [ conversation.id, profile.id ],
);
if (maps.length != 1) {
throw ArgumentError('Invalid user id');
}
conversation.name = maps[0]['username'];
await db.insert(
'conversations',
conversation.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
return conversation;
}
Future<Conversation> getConversationById(String id) async {
final db = await getDatabaseConnection();
final List<Map<String, dynamic>> maps = await db.query(
'conversations',
where: 'id = ?',
whereArgs: [id],
);
if (maps.length != 1) {
throw ArgumentError('Invalid user id');
}
File? file;
if (maps[0]['file'] != null && maps[0]['file'] != '') {
file = File(maps[0]['file']);
}
return Conversation(
id: maps[0]['id'],
userId: maps[0]['user_id'],
symmetricKey: maps[0]['symmetric_key'],
admin: maps[0]['admin'] == 1,
name: maps[0]['name'],
twoUser: maps[0]['two_user'] == 1,
status: ConversationStatus.values[maps[0]['status']],
isRead: maps[0]['is_read'] == 1,
icon: file,
messageExpiryDefault: maps[0]['message_expiry'],
adminAddMembers: maps[0]['admin_add_members'] == 1,
adminEditInfo: maps[0]['admin_edit_info'] == 1,
adminSendMessages: maps[0]['admin_send_messages'] == 1,
);
}
// A method that retrieves all the dogs from the dogs table.
Future<List<Conversation>> getConversations() async {
final db = await getDatabaseConnection();
final List<Map<String, dynamic>> maps = await db.query(
'conversations',
orderBy: 'name',
);
return List.generate(maps.length, (i) {
File? file;
if (maps[i]['file'] != null && maps[i]['file'] != '') {
file = File(maps[i]['file']);
}
return Conversation(
id: maps[i]['id'],
userId: maps[i]['user_id'],
symmetricKey: maps[i]['symmetric_key'],
admin: maps[i]['admin'] == 1,
name: maps[i]['name'],
twoUser: maps[i]['two_user'] == 1,
status: ConversationStatus.values[maps[i]['status']],
isRead: maps[i]['is_read'] == 1,
icon: file,
messageExpiryDefault: maps[i]['message_expiry'] ?? 'no_expiry',
adminAddMembers: maps[i]['admin_add_members'] == 1,
adminEditInfo: maps[i]['admin_edit_info'] == 1,
adminSendMessages: maps[i]['admin_send_messages'] == 1,
);
});
}
Future<Conversation?> getTwoUserConversation(String userId) async {
final db = await getDatabaseConnection();
MyProfile profile = await MyProfile.getProfile();
final List<Map<String, dynamic>> maps = await db.rawQuery(
'''
SELECT conversations.* FROM conversations
LEFT JOIN conversation_users ON conversation_users.conversation_id = conversations.id
WHERE conversation_users.user_id = ?
AND conversation_users.user_id != ?
AND conversations.two_user = 1
''',
[ userId, profile.id ],
);
if (maps.length != 1) {
return null;
}
return Conversation(
id: maps[0]['id'],
userId: maps[0]['user_id'],
symmetricKey: maps[0]['symmetric_key'],
admin: maps[0]['admin'] == 1,
name: maps[0]['name'],
twoUser: maps[0]['two_user'] == 1,
status: ConversationStatus.values[maps[0]['status']],
isRead: maps[0]['is_read'] == 1,
messageExpiryDefault: maps[0]['message_expiry'],
adminAddMembers: maps[0]['admin_add_members'] == 1,
adminEditInfo: maps[0]['admin_edit_info'] == 1,
adminSendMessages: maps[0]['admin_send_messages'] == 1,
);
}
class Conversation {
String id;
String userId;
String symmetricKey;
bool admin;
String name;
bool twoUser;
ConversationStatus status;
bool isRead;
String messageExpiryDefault = 'no_expiry';
bool adminAddMembers = true;
bool adminEditInfo = true;
bool adminSendMessages = false;
File? icon;
Conversation({
required this.id,
required this.userId,
required this.symmetricKey,
required this.admin,
required this.name,
required this.twoUser,
required this.status,
required this.isRead,
required this.messageExpiryDefault,
required this.adminAddMembers,
required this.adminEditInfo,
required this.adminSendMessages,
this.icon,
});
factory Conversation.fromJson(Map<String, dynamic> json, RSAPrivateKey privKey) {
var symmetricKeyDecrypted = CryptoUtils.rsaDecrypt(
base64.decode(json['symmetric_key']),
privKey,
);
var id = AesHelper.aesDecrypt(
symmetricKeyDecrypted,
base64.decode(json['conversation_detail_id']),
);
var admin = AesHelper.aesDecrypt(
symmetricKeyDecrypted,
base64.decode(json['admin']),
);
return Conversation(
id: id,
userId: json['user_id'],
symmetricKey: base64.encode(symmetricKeyDecrypted),
admin: admin == 'true',
name: 'Unknown',
twoUser: false,
status: ConversationStatus.complete,
isRead: true,
messageExpiryDefault: 'no_expiry',
adminAddMembers: true,
adminEditInfo: true,
adminSendMessages: false,
);
}
Future<Map<String, dynamic>> payloadJson({ bool includeUsers = true }) async {
MyProfile profile = await MyProfile.getProfile();
var symKey = base64.decode(symmetricKey);
if (!includeUsers) {
return {
'id': id,
'name': AesHelper.aesEncrypt(symKey, Uint8List.fromList(name.codeUnits)),
'users': await getEncryptedConversationUsers(this, symKey),
};
}
List<ConversationUser> users = await getConversationUsers(this);
List<Object> userConversations = [];
for (ConversationUser user in users) {
RSAPublicKey pubKey = profile.publicKey!;
String newId = id;
if (profile.id != user.userId) {
Friend friend = await getFriendByFriendId(user.userId);
pubKey = friend.publicKey;
newId = (const Uuid()).v4();
}
userConversations.add({
'id': newId,
'user_id': user.userId,
'conversation_detail_id': AesHelper.aesEncrypt(symKey, Uint8List.fromList(id.codeUnits)),
'admin': AesHelper.aesEncrypt(symKey, Uint8List.fromList((user.admin ? 'true' : 'false').codeUnits)),
'symmetric_key': base64.encode(CryptoUtils.rsaEncrypt(symKey, pubKey)),
});
}
Map<String, dynamic> returnData = {
'id': id,
'name': AesHelper.aesEncrypt(symKey, Uint8List.fromList(name.codeUnits)),
'users': await getEncryptedConversationUsers(this, symKey),
'two_user': AesHelper.aesEncrypt(symKey, Uint8List.fromList((twoUser ? 'true' : 'false').codeUnits)),
'message_expiry': messageExpiryDefault,
'admin_add_members': AesHelper.aesEncrypt(symKey, Uint8List.fromList((adminAddMembers ? 'true' : 'false').codeUnits)),
'admin_edit_info': AesHelper.aesEncrypt(symKey, Uint8List.fromList((adminEditInfo ? 'true' : 'false').codeUnits)),
'admin_send_messages': AesHelper.aesEncrypt(symKey, Uint8List.fromList((adminSendMessages ? 'true' : 'false').codeUnits)),
'user_conversations': userConversations,
};
if (icon != null) {
returnData['attachment'] = {
'data': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(icon!.readAsBytesSync())),
'mimetype': lookupMimeType(icon!.path),
'extension': getExtension(icon!.path),
};
}
return returnData;
}
Map<String, dynamic> payloadImageJson() {
if (icon == null) {
return {};
}
return {
'data': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(icon!.readAsBytesSync())),
'mimetype': lookupMimeType(icon!.path),
'extension': getExtension(icon!.path),
};
}
Map<String, dynamic> toMap() {
return {
'id': id,
'user_id': userId,
'symmetric_key': symmetricKey,
'admin': admin ? 1 : 0,
'name': name,
'two_user': twoUser ? 1 : 0,
'status': status.index,
'is_read': isRead ? 1 : 0,
'file': icon != null ? icon!.path : null,
'message_expiry': messageExpiryDefault,
'admin_add_members': adminAddMembers ? 1 : 0,
'admin_edit_info': adminEditInfo ? 1 : 0,
'admin_send_messages': adminSendMessages ? 1 : 0,
};
}
@override
String toString() {
return '''
id: $id
userId: $userId
name: $name
admin: $admin''';
}
Future<Message?> getRecentMessage() async {
final db = await getDatabaseConnection();
final List<Map<String, dynamic>> maps = await db.rawQuery(
'''
SELECT * FROM messages WHERE association_key IN (
SELECT association_key FROM conversation_users WHERE conversation_id = ?
)
ORDER BY created_at DESC
LIMIT 1;
''',
[ id ],
);
if (maps.isEmpty) {
return null;
}
return TextMessage(
id: maps[0]['id'],
symmetricKey: maps[0]['symmetric_key'],
userSymmetricKey: maps[0]['user_symmetric_key'],
text: maps[0]['data'] ?? 'Image',
senderId: maps[0]['sender_id'],
senderUsername: maps[0]['sender_username'],
associationKey: maps[0]['association_key'],
createdAt: maps[0]['created_at'],
failedToSend: maps[0]['failed_to_send'] == 1,
);
}
}
enum ConversationStatus {
complete,
pending,
error,
}