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.

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