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.

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