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

  1. import 'dart:convert';
  2. import 'dart:io';
  3. import 'dart:typed_data';
  4. import 'package:mime/mime.dart';
  5. import 'package:pointycastle/export.dart';
  6. import 'package:sqflite/sqflite.dart';
  7. import 'package:uuid/uuid.dart';
  8. import '/database/models/messages.dart';
  9. import '/database/models/text_messages.dart';
  10. import '/database/models/conversation_users.dart';
  11. import '/database/models/friends.dart';
  12. import '/database/models/my_profile.dart';
  13. import '/utils/encryption/aes_helper.dart';
  14. import '/utils/encryption/crypto_utils.dart';
  15. import '/utils/storage/database.dart';
  16. import '/utils/strings.dart';
  17. import '/utils/storage/write_file.dart';
  18. Future<Conversation> createConversation(String title, List<Friend> friends, bool twoUser) async {
  19. final db = await getDatabaseConnection();
  20. MyProfile profile = await MyProfile.getProfile();
  21. var uuid = const Uuid();
  22. final String conversationId = uuid.v4();
  23. Uint8List symmetricKey = AesHelper.deriveKey(generateRandomString(32));
  24. Conversation conversation = Conversation(
  25. id: conversationId,
  26. userId: profile.id,
  27. symmetricKey: base64.encode(symmetricKey),
  28. admin: true,
  29. name: title,
  30. twoUser: twoUser,
  31. status: ConversationStatus.pending,
  32. isRead: true,
  33. messageExpiryDefault: 'no_expiry',
  34. adminAddMembers: true,
  35. adminEditInfo: true,
  36. adminSendMessages: false,
  37. );
  38. await db.insert(
  39. 'conversations',
  40. conversation.toMap(),
  41. conflictAlgorithm: ConflictAlgorithm.replace,
  42. );
  43. await db.insert(
  44. 'conversation_users',
  45. ConversationUser(
  46. id: uuid.v4(),
  47. userId: profile.id,
  48. conversationId: conversationId,
  49. username: profile.username,
  50. associationKey: uuid.v4(),
  51. publicKey: profile.publicKey!,
  52. admin: true,
  53. ).toMap(),
  54. conflictAlgorithm: ConflictAlgorithm.fail,
  55. );
  56. for (Friend friend in friends) {
  57. await db.insert(
  58. 'conversation_users',
  59. ConversationUser(
  60. id: uuid.v4(),
  61. userId: friend.friendId,
  62. conversationId: conversationId,
  63. username: friend.username,
  64. associationKey: uuid.v4(),
  65. publicKey: friend.publicKey,
  66. admin: twoUser ? true : false,
  67. ).toMap(),
  68. conflictAlgorithm: ConflictAlgorithm.replace,
  69. );
  70. }
  71. if (twoUser) {
  72. List<Map<String, dynamic>> maps = await db.query(
  73. 'conversation_users',
  74. where: 'conversation_id = ? AND user_id != ?',
  75. whereArgs: [ conversation.id, profile.id ],
  76. );
  77. if (maps.length != 1) {
  78. throw ArgumentError('Invalid user id');
  79. }
  80. conversation.name = maps[0]['username'];
  81. await db.insert(
  82. 'conversations',
  83. conversation.toMap(),
  84. conflictAlgorithm: ConflictAlgorithm.replace,
  85. );
  86. }
  87. return conversation;
  88. }
  89. Future<Conversation> getConversationById(String id) async {
  90. final db = await getDatabaseConnection();
  91. final List<Map<String, dynamic>> maps = await db.query(
  92. 'conversations',
  93. where: 'id = ?',
  94. whereArgs: [id],
  95. );
  96. if (maps.length != 1) {
  97. throw ArgumentError('Invalid user id');
  98. }
  99. File? file;
  100. if (maps[0]['file'] != null && maps[0]['file'] != '') {
  101. file = File(maps[0]['file']);
  102. }
  103. return Conversation(
  104. id: maps[0]['id'],
  105. userId: maps[0]['user_id'],
  106. symmetricKey: maps[0]['symmetric_key'],
  107. admin: maps[0]['admin'] == 1,
  108. name: maps[0]['name'],
  109. twoUser: maps[0]['two_user'] == 1,
  110. status: ConversationStatus.values[maps[0]['status']],
  111. isRead: maps[0]['is_read'] == 1,
  112. icon: file,
  113. messageExpiryDefault: maps[0]['message_expiry'],
  114. adminAddMembers: maps[0]['admin_add_members'] == 1,
  115. adminEditInfo: maps[0]['admin_edit_info'] == 1,
  116. adminSendMessages: maps[0]['admin_send_messages'] == 1,
  117. );
  118. }
  119. // A method that retrieves all the dogs from the dogs table.
  120. Future<List<Conversation>> getConversations() async {
  121. final db = await getDatabaseConnection();
  122. final List<Map<String, dynamic>> maps = await db.query(
  123. 'conversations',
  124. orderBy: 'name',
  125. );
  126. return List.generate(maps.length, (i) {
  127. File? file;
  128. if (maps[i]['file'] != null && maps[i]['file'] != '') {
  129. file = File(maps[i]['file']);
  130. }
  131. return Conversation(
  132. id: maps[i]['id'],
  133. userId: maps[i]['user_id'],
  134. symmetricKey: maps[i]['symmetric_key'],
  135. admin: maps[i]['admin'] == 1,
  136. name: maps[i]['name'],
  137. twoUser: maps[i]['two_user'] == 1,
  138. status: ConversationStatus.values[maps[i]['status']],
  139. isRead: maps[i]['is_read'] == 1,
  140. icon: file,
  141. messageExpiryDefault: maps[i]['message_expiry'] ?? 'no_expiry',
  142. adminAddMembers: maps[i]['admin_add_members'] == 1,
  143. adminEditInfo: maps[i]['admin_edit_info'] == 1,
  144. adminSendMessages: maps[i]['admin_send_messages'] == 1,
  145. );
  146. });
  147. }
  148. Future<Conversation?> getTwoUserConversation(String userId) async {
  149. final db = await getDatabaseConnection();
  150. MyProfile profile = await MyProfile.getProfile();
  151. final List<Map<String, dynamic>> maps = await db.rawQuery(
  152. '''
  153. SELECT conversations.* FROM conversations
  154. LEFT JOIN conversation_users ON conversation_users.conversation_id = conversations.id
  155. WHERE conversation_users.user_id = ?
  156. AND conversation_users.user_id != ?
  157. AND conversations.two_user = 1
  158. ''',
  159. [ userId, profile.id ],
  160. );
  161. if (maps.length != 1) {
  162. return null;
  163. }
  164. return Conversation(
  165. id: maps[0]['id'],
  166. userId: maps[0]['user_id'],
  167. symmetricKey: maps[0]['symmetric_key'],
  168. admin: maps[0]['admin'] == 1,
  169. name: maps[0]['name'],
  170. twoUser: maps[0]['two_user'] == 1,
  171. status: ConversationStatus.values[maps[0]['status']],
  172. isRead: maps[0]['is_read'] == 1,
  173. messageExpiryDefault: maps[0]['message_expiry'],
  174. adminAddMembers: maps[0]['admin_add_members'] == 1,
  175. adminEditInfo: maps[0]['admin_edit_info'] == 1,
  176. adminSendMessages: maps[0]['admin_send_messages'] == 1,
  177. );
  178. }
  179. class Conversation {
  180. String id;
  181. String userId;
  182. String symmetricKey;
  183. bool admin;
  184. String name;
  185. bool twoUser;
  186. ConversationStatus status;
  187. bool isRead;
  188. String messageExpiryDefault = 'no_expiry';
  189. bool adminAddMembers = true;
  190. bool adminEditInfo = true;
  191. bool adminSendMessages = false;
  192. File? icon;
  193. Conversation({
  194. required this.id,
  195. required this.userId,
  196. required this.symmetricKey,
  197. required this.admin,
  198. required this.name,
  199. required this.twoUser,
  200. required this.status,
  201. required this.isRead,
  202. required this.messageExpiryDefault,
  203. required this.adminAddMembers,
  204. required this.adminEditInfo,
  205. required this.adminSendMessages,
  206. this.icon,
  207. });
  208. factory Conversation.fromJson(Map<String, dynamic> json, RSAPrivateKey privKey) {
  209. var symmetricKeyDecrypted = CryptoUtils.rsaDecrypt(
  210. base64.decode(json['symmetric_key']),
  211. privKey,
  212. );
  213. var id = AesHelper.aesDecrypt(
  214. symmetricKeyDecrypted,
  215. base64.decode(json['conversation_detail_id']),
  216. );
  217. var admin = AesHelper.aesDecrypt(
  218. symmetricKeyDecrypted,
  219. base64.decode(json['admin']),
  220. );
  221. return Conversation(
  222. id: id,
  223. userId: json['user_id'],
  224. symmetricKey: base64.encode(symmetricKeyDecrypted),
  225. admin: admin == 'true',
  226. name: 'Unknown',
  227. twoUser: false,
  228. status: ConversationStatus.complete,
  229. isRead: true,
  230. messageExpiryDefault: 'no_expiry',
  231. adminAddMembers: true,
  232. adminEditInfo: true,
  233. adminSendMessages: false,
  234. );
  235. }
  236. Future<Map<String, dynamic>> payloadJson({ bool includeUsers = true }) async {
  237. MyProfile profile = await MyProfile.getProfile();
  238. var symKey = base64.decode(symmetricKey);
  239. if (!includeUsers) {
  240. return {
  241. 'id': id,
  242. 'name': AesHelper.aesEncrypt(symKey, Uint8List.fromList(name.codeUnits)),
  243. 'users': await getEncryptedConversationUsers(this, symKey),
  244. };
  245. }
  246. List<ConversationUser> users = await getConversationUsers(this);
  247. List<Object> userConversations = [];
  248. for (ConversationUser user in users) {
  249. RSAPublicKey pubKey = profile.publicKey!;
  250. String newId = id;
  251. if (profile.id != user.userId) {
  252. Friend friend = await getFriendByFriendId(user.userId);
  253. pubKey = friend.publicKey;
  254. newId = (const Uuid()).v4();
  255. }
  256. userConversations.add({
  257. 'id': newId,
  258. 'user_id': user.userId,
  259. 'conversation_detail_id': AesHelper.aesEncrypt(symKey, Uint8List.fromList(id.codeUnits)),
  260. 'admin': AesHelper.aesEncrypt(symKey, Uint8List.fromList((user.admin ? 'true' : 'false').codeUnits)),
  261. 'symmetric_key': base64.encode(CryptoUtils.rsaEncrypt(symKey, pubKey)),
  262. });
  263. }
  264. Map<String, dynamic> returnData = {
  265. 'id': id,
  266. 'name': AesHelper.aesEncrypt(symKey, Uint8List.fromList(name.codeUnits)),
  267. 'users': await getEncryptedConversationUsers(this, symKey),
  268. 'two_user': AesHelper.aesEncrypt(symKey, Uint8List.fromList((twoUser ? 'true' : 'false').codeUnits)),
  269. 'message_expiry': messageExpiryDefault,
  270. 'admin_add_members': AesHelper.aesEncrypt(symKey, Uint8List.fromList((adminAddMembers ? 'true' : 'false').codeUnits)),
  271. 'admin_edit_info': AesHelper.aesEncrypt(symKey, Uint8List.fromList((adminEditInfo ? 'true' : 'false').codeUnits)),
  272. 'admin_send_messages': AesHelper.aesEncrypt(symKey, Uint8List.fromList((adminSendMessages ? 'true' : 'false').codeUnits)),
  273. 'user_conversations': userConversations,
  274. };
  275. if (icon != null) {
  276. returnData['attachment'] = {
  277. 'data': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(icon!.readAsBytesSync())),
  278. 'mimetype': lookupMimeType(icon!.path),
  279. 'extension': getExtension(icon!.path),
  280. };
  281. }
  282. return returnData;
  283. }
  284. Map<String, dynamic> payloadImageJson() {
  285. if (icon == null) {
  286. return {};
  287. }
  288. return {
  289. 'data': AesHelper.aesEncrypt(symmetricKey, Uint8List.fromList(icon!.readAsBytesSync())),
  290. 'mimetype': lookupMimeType(icon!.path),
  291. 'extension': getExtension(icon!.path),
  292. };
  293. }
  294. Map<String, dynamic> toMap() {
  295. return {
  296. 'id': id,
  297. 'user_id': userId,
  298. 'symmetric_key': symmetricKey,
  299. 'admin': admin ? 1 : 0,
  300. 'name': name,
  301. 'two_user': twoUser ? 1 : 0,
  302. 'status': status.index,
  303. 'is_read': isRead ? 1 : 0,
  304. 'file': icon != null ? icon!.path : null,
  305. 'message_expiry': messageExpiryDefault,
  306. 'admin_add_members': adminAddMembers ? 1 : 0,
  307. 'admin_edit_info': adminEditInfo ? 1 : 0,
  308. 'admin_send_messages': adminSendMessages ? 1 : 0,
  309. };
  310. }
  311. @override
  312. String toString() {
  313. return '''
  314. id: $id
  315. userId: $userId
  316. name: $name
  317. admin: $admin''';
  318. }
  319. Future<Message?> getRecentMessage() async {
  320. final db = await getDatabaseConnection();
  321. final List<Map<String, dynamic>> maps = await db.rawQuery(
  322. '''
  323. SELECT * FROM messages WHERE association_key IN (
  324. SELECT association_key FROM conversation_users WHERE conversation_id = ?
  325. )
  326. ORDER BY created_at DESC
  327. LIMIT 1;
  328. ''',
  329. [ id ],
  330. );
  331. if (maps.isEmpty) {
  332. return null;
  333. }
  334. return TextMessage(
  335. id: maps[0]['id'],
  336. symmetricKey: maps[0]['symmetric_key'],
  337. userSymmetricKey: maps[0]['user_symmetric_key'],
  338. text: maps[0]['data'] ?? 'Image',
  339. senderId: maps[0]['sender_id'],
  340. senderUsername: maps[0]['sender_username'],
  341. associationKey: maps[0]['association_key'],
  342. createdAt: maps[0]['created_at'],
  343. failedToSend: maps[0]['failed_to_send'] == 1,
  344. );
  345. }
  346. }
  347. enum ConversationStatus {
  348. complete,
  349. pending,
  350. error,
  351. }