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.

448 lines
12 KiB

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