E2EE and Keypair

This commit is contained in:
LittleSheep 2024-05-12 20:15:12 +08:00
parent 08d0a99b10
commit 98547708af
20 changed files with 665 additions and 115 deletions

View File

@ -38,6 +38,8 @@
"cancel": "Cancel", "cancel": "Cancel",
"report": "Report", "report": "Report",
"reply": "Reply", "reply": "Reply",
"export": "Export",
"import": "Import",
"settings": "Settings", "settings": "Settings",
"errorHappened": "An Error Occurred", "errorHappened": "An Error Occurred",
"notification": "Notification", "notification": "Notification",
@ -56,6 +58,11 @@
"friendAddDone": "Friend request sent, go reach your friend!", "friendAddDone": "Friend request sent, go reach your friend!",
"personalize": "Personalize", "personalize": "Personalize",
"personalizeApplied": "Your account information has been updated, some fields may take a while to fully applied.", "personalizeApplied": "Your account information has been updated, some fields may take a while to fully applied.",
"keypair": "Keypair",
"keypairGenerated": "A new set of keypair has been generated. Automatically set it to active.",
"keypairSecretCode": "Secret Code",
"keypairImportHint": "You can paste the exported secret code here to import all keys in it.",
"keypairExportHint": "You can copy the exported secret code to other device to import all keys into it. Do not share it with anybody else!",
"reaction": "Reaction", "reaction": "Reaction",
"reactVerb": "React", "reactVerb": "React",
"post": "Post", "post": "Post",
@ -107,6 +114,7 @@
"chatChannelAliasLabel": "Channel Alias", "chatChannelAliasLabel": "Channel Alias",
"chatChannelNameLabel": "Channel Name", "chatChannelNameLabel": "Channel Name",
"chatChannelDescriptionLabel": "Channel Description", "chatChannelDescriptionLabel": "Channel Description",
"chatChannelEncryptedLabel": "Encrypted Channel",
"chatChannelLeaveConfirm": "Are you sure you want to leave this channel? Your message will be stored, but if you rejoin this channel later, you will lose your control of your previous messages.", "chatChannelLeaveConfirm": "Are you sure you want to leave this channel? Your message will be stored, but if you rejoin this channel later, you will lose your control of your previous messages.",
"chatChannelDeleteConfirm": "Are you sure you want to delete this channel? All messages in this channel will be gone forever. This operation cannot be revert!", "chatChannelDeleteConfirm": "Are you sure you want to delete this channel? All messages in this channel will be gone forever. This operation cannot be revert!",
"chatCall": "Call", "chatCall": "Call",
@ -124,9 +132,13 @@
"chatCallDisconnectConfirm": "Are you sure you want to disconnect? You can reconnect after this if you want.", "chatCallDisconnectConfirm": "Are you sure you want to disconnect? You can reconnect after this if you want.",
"chatCallChangeSpeaker": "Change Speaker", "chatCallChangeSpeaker": "Change Speaker",
"chatMessagePlaceholder": "Write a message...", "chatMessagePlaceholder": "Write a message...",
"chatMessageEncryptedPlaceholder": "Write a encrypted message...",
"chatMessageSending": "Now delivering your messages...", "chatMessageSending": "Now delivering your messages...",
"chatMessageEditNotify": "You are about editing a message.", "chatMessageEditNotify": "You are about editing a message.",
"chatMessageReplyNotify": "You are about replying a message.", "chatMessageReplyNotify": "You are about replying a message.",
"chatMessageDeleteConfirm": "Are you sure you want to delete this message? This operation cannot be revert and no local history is saved!", "chatMessageDeleteConfirm": "Are you sure you want to delete this message? This operation cannot be revert and no local history is saved!",
"chatMessageViewSource": "View source" "chatMessageViewSource": "View source",
"chatMessageUnableDecryptWaiting": "Waiting for encryption key...",
"chatMessageUnableDecryptUnsupported": "Unable to decrypt the message, encryption algorithm unsupported",
"chatMessageUnableDecryptMissing": "Unable to decrypt the message, missing encryption key"
} }

View File

@ -38,6 +38,8 @@
"exit": "离开", "exit": "离开",
"report": "举报", "report": "举报",
"reply": "回复", "reply": "回复",
"export": "导出",
"import": "导入",
"settings": "设置", "settings": "设置",
"errorHappened": "发生了错误", "errorHappened": "发生了错误",
"notification": "通知", "notification": "通知",
@ -56,6 +58,11 @@
"friendAddDone": "好友请求已发送,快告诉你的朋友吧!", "friendAddDone": "好友请求已发送,快告诉你的朋友吧!",
"personalize": "个性化", "personalize": "个性化",
"personalizeApplied": "您的账号信息已被更新,部分信息可能需要一段时间来同步。", "personalizeApplied": "您的账号信息已被更新,部分信息可能需要一段时间来同步。",
"keypair": "密钥对",
"keypairGenerated": "已生成一套新的密钥对,并且设为活跃的密钥。",
"keypairSecretCode": "神秘代码",
"keypairImportHint": "你可以将别的设备导出的神秘代码粘贴到这里来导入其中的所有密钥。",
"keypairExportHint": "你可以将这个导出的神秘代码到你的别的设备来导入这个设备所包含的密钥,但绝对不要发送给其他人!",
"reaction": "反应", "reaction": "反应",
"reactVerb": "作出反应", "reactVerb": "作出反应",
"post": "帖子", "post": "帖子",
@ -107,6 +114,7 @@
"chatChannelAliasLabel": "频道别名", "chatChannelAliasLabel": "频道别名",
"chatChannelNameLabel": "频道名称", "chatChannelNameLabel": "频道名称",
"chatChannelDescriptionLabel": "频道简介", "chatChannelDescriptionLabel": "频道简介",
"chatChannelEncryptedLabel": "加密频道",
"chatChannelLeaveConfirm": "你确定你要离开这个频道吗?你在这个频道里的消息将被存储下来,但是当你重新加入本频道后你将会失去对你之前消息的权限。", "chatChannelLeaveConfirm": "你确定你要离开这个频道吗?你在这个频道里的消息将被存储下来,但是当你重新加入本频道后你将会失去对你之前消息的权限。",
"chatChannelDeleteConfirm": "你确定你要删除这个频道吗?这个频道里的所有消息都将消失,并且不可被反转!", "chatChannelDeleteConfirm": "你确定你要删除这个频道吗?这个频道里的所有消息都将消失,并且不可被反转!",
"chatCall": "通话", "chatCall": "通话",
@ -124,9 +132,13 @@
"chatCallDisconnect": "断开连接", "chatCallDisconnect": "断开连接",
"chatCallDisconnectConfirm": "你确定你要断开连接吗?你可以之后在任何时候重新连接。", "chatCallDisconnectConfirm": "你确定你要断开连接吗?你可以之后在任何时候重新连接。",
"chatMessagePlaceholder": "发条消息……", "chatMessagePlaceholder": "发条消息……",
"chatMessageEncryptedPlaceholder": "发条加密信息……",
"chatMessageSending": "正在送出你的信息……", "chatMessageSending": "正在送出你的信息……",
"chatMessageEditNotify": "你正在编辑信息中……", "chatMessageEditNotify": "你正在编辑信息中……",
"chatMessageReplyNotify": "你正在回复消息中……", "chatMessageReplyNotify": "你正在回复消息中……",
"chatMessageDeleteConfirm": "你确定要删除这条消息吗?这条消息将永远的从所有人的视图中消失,并且不会有本地消息记录保存!", "chatMessageDeleteConfirm": "你确定要删除这条消息吗?这条消息将永远的从所有人的视图中消失,并且不会有本地消息记录保存!",
"chatMessageViewSource": "查看原始信息" "chatMessageViewSource": "查看原始信息",
"chatMessageUnableDecryptWaiting": "正在等待解密密钥……",
"chatMessageUnableDecryptUnsupported": "无法解密信息,不支持加密的算法",
"chatMessageUnableDecryptMissing": "无法解密信息,缺失解密密钥"
} }

View File

@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/providers/chat.dart'; import 'package:solian/providers/chat.dart';
import 'package:solian/providers/friend.dart'; import 'package:solian/providers/friend.dart';
import 'package:solian/providers/keypair.dart';
import 'package:solian/providers/navigation.dart'; import 'package:solian/providers/navigation.dart';
import 'package:solian/providers/notify.dart'; import 'package:solian/providers/notify.dart';
import 'package:solian/providers/realm.dart'; import 'package:solian/providers/realm.dart';
@ -12,7 +13,6 @@ import 'package:solian/utils/timeago.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/utils/video_player.dart'; import 'package:solian/utils/video_player.dart';
import 'package:solian/widgets/chat/call/call_overlay.dart'; import 'package:solian/widgets/chat/call/call_overlay.dart';
import 'package:solian/widgets/notification_notifier.dart';
void main() { void main() {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@ -45,13 +45,14 @@ class SolianApp extends StatelessWidget {
ChangeNotifierProvider(create: (_) => NotifyProvider()), ChangeNotifierProvider(create: (_) => NotifyProvider()),
ChangeNotifierProvider(create: (_) => FriendProvider()), ChangeNotifierProvider(create: (_) => FriendProvider()),
ChangeNotifierProvider(create: (_) => RealmProvider()), ChangeNotifierProvider(create: (_) => RealmProvider()),
ChangeNotifierProvider(create: (_) => KeypairProvider()),
], ],
child: Overlay( child: Overlay(
initialEntries: [ initialEntries: [
OverlayEntry(builder: (context) { OverlayEntry(builder: (context) {
return ScaffoldMessenger( return ScaffoldMessenger(
child: Scaffold( child: Scaffold(
body: NotificationNotifier(child: child ?? Container()), body: child ?? Container(),
), ),
); );
}), }),

View File

@ -12,6 +12,7 @@ class Channel {
Account account; Account account;
int accountId; int accountId;
int? realmId; int? realmId;
bool isEncrypted;
bool isAvailable = false; bool isAvailable = false;
@ -26,6 +27,7 @@ class Channel {
required this.type, required this.type,
required this.account, required this.account,
required this.accountId, required this.accountId,
required this.isEncrypted,
this.realmId, this.realmId,
}); });
@ -41,6 +43,7 @@ class Channel {
account: Account.fromJson(json['account']), account: Account.fromJson(json['account']),
accountId: json['account_id'], accountId: json['account_id'],
realmId: json['realm_id'], realmId: json['realm_id'],
isEncrypted: json['is_encrypted'],
); );
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
@ -55,6 +58,7 @@ class Channel {
'account': account, 'account': account,
'account_id': accountId, 'account_id': accountId,
'realm_id': realmId, 'realm_id': realmId,
'is_encrypted': isEncrypted,
}; };
} }

32
lib/models/keypair.dart Normal file
View File

@ -0,0 +1,32 @@
class Keypair {
final String id;
final String algorithm;
final String publicKey;
final String? privateKey;
final bool isOwned;
Keypair({
required this.id,
required this.algorithm,
required this.publicKey,
required this.privateKey,
this.isOwned = false,
});
factory Keypair.fromJson(Map<String, dynamic> json) => Keypair(
id: json['id'],
algorithm: json['algorithm'],
publicKey: json['public_key'],
privateKey: json['private_key'],
isOwned: json['is_owned'],
);
Map<String, dynamic> toJson() => {
'id': id,
'algorithm': algorithm,
'public_key': publicKey,
'private_key': privateKey,
'is_owned': isOwned,
};
}

146
lib/providers/keypair.dart Normal file
View File

@ -0,0 +1,146 @@
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:encrypt/encrypt.dart' as encrypt;
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:solian/models/keypair.dart';
import 'package:solian/models/packet.dart';
import 'package:uuid/uuid.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
class KeypairProvider extends ChangeNotifier {
static const storage = FlutterSecureStorage();
static const encryptIV = 'WT7s~><Ae?YrJd)D';
WebSocketChannel? channel;
String? activeKeyId;
Map<String, Keypair> keys = {};
List<String> requestingKeys = List.empty(growable: true);
KeypairProvider() {
loadKeys();
}
void loadKeys() async {
final result = await storage.read(key: 'keypair');
if (result != null) {
jsonDecode(result).values.forEach((x) {
keys[x['id']] = Keypair.fromJson(x);
});
activeKeyId = await storage.read(key: 'keypairActive');
}
notifyListeners();
}
void saveKeys() async {
await storage.write(key: 'keypair', value: jsonEncode(keys));
if (activeKeyId != null) {
await storage.write(key: 'keypairActive', value: activeKeyId);
}
}
void receiveKeypair(Keypair kp) {
keys[kp.id] = kp;
requestingKeys.remove(kp.id);
saveKeys();
notifyListeners();
}
Keypair? provideKeypair(String id) {
print(id);
print(keys[id]);
return keys[id];
}
void importKeys(String code) {
final result = jsonDecode(utf8.fuse(base64).decode(code)).map((x) => Keypair.fromJson(x)).toList();
for (final item in result) {
if (item is Keypair) {
keys[item.id] = item;
}
}
saveKeys();
notifyListeners();
}
void setActiveKey(String id) {
if (keys[id] == null) return;
activeKeyId = id;
saveKeys();
notifyListeners();
}
void clearKeys() {
keys = {};
storage.delete(key: 'keypairActive');
saveKeys();
}
bool requestKey(String id, String algorithm, int uid) {
if (channel == null) return false;
if (requestingKeys.contains(id)) return false;
channel!.sink.add(jsonEncode(
NetworkPackage(method: 'kex.request', payload: {
'request_id': const Uuid().v4(),
'keypair_id': id,
'algorithm': algorithm,
'owner_id': uid,
'deadline': 3,
}).toJson(),
));
requestingKeys.add(id);
notifyListeners();
return true;
}
String? encodeViaAESKey(String keypairId, String content) {
if (keys[keypairId] == null) {
return null;
} else if (keys[keypairId]?.algorithm != 'aes') {
throw Exception('invalid algorithm');
}
final kp = keys[keypairId]!;
final iv = encrypt.IV.fromUtf8(encryptIV);
final key = encrypt.Key.fromBase64(kp.publicKey);
final encryptor = encrypt.Encrypter(encrypt.AES(key, mode: encrypt.AESMode.sic, padding: null));
return encryptor.encryptBytes(utf8.encode(content), iv: iv).base64;
}
String? decodeViaAESKey(String keypairId, String encrypted) {
if (keys[keypairId] == null) {
return null;
} else if (keys[keypairId]?.algorithm != 'aes') {
throw Exception('invalid algorithm');
}
final kp = keys[keypairId]!;
final iv = encrypt.IV.fromUtf8(encryptIV);
final key = encrypt.Key.fromBase64(kp.publicKey);
final encryptor = encrypt.Encrypter(encrypt.AES(key, mode: encrypt.AESMode.sic, padding: null));
return utf8.decode(encryptor.decryptBytes(encrypt.Encrypted.fromBase64(encrypted), iv: iv));
}
Keypair generateAESKey() {
final random = Random.secure();
final values = List<int>.generate(32, (i) => random.nextInt(256));
final key = Uint8List.fromList(values);
final kp = Keypair(
id: const Uuid().v4(),
algorithm: 'aes',
publicKey: base64.encode(key),
privateKey: null,
isOwned: true,
);
keys[kp.id] = kp;
return kp;
}
}

View File

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:livekit_client/livekit_client.dart'; import 'package:livekit_client/livekit_client.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:solian/models/keypair.dart';
import 'package:solian/models/packet.dart'; import 'package:solian/models/packet.dart';
import 'package:solian/models/pagination.dart'; import 'package:solian/models/pagination.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
@ -63,9 +64,13 @@ class NotifyProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> connect(AuthProvider auth) async { Future<WebSocketChannel?> connect(
AuthProvider auth, {
Keypair? Function(String id)? onKexRequest,
Function(Keypair kp)? onKexProvide,
}) async {
if (auth.client == null) await auth.loadClient(); if (auth.client == null) await auth.loadClient();
if (!await auth.isAuthorized()) return; if (!await auth.isAuthorized()) return null;
await auth.client!.refreshToken(auth.client!.currentRefreshToken!); await auth.client!.refreshToken(auth.client!.currentRefreshToken!);
@ -87,19 +92,40 @@ class NotifyProvider extends ChangeNotifier {
switch (result.method) { switch (result.method) {
case 'notifications.new': case 'notifications.new':
final result = model.Notification.fromJson(jsonDecode(event)); final result = model.Notification.fromJson(jsonDecode(event));
onRemoteMessage(result); unreadAmount++;
notifications.add(result);
notifyListeners();
notifyMessage(result.subject, result.content); notifyMessage(result.subject, result.content);
break;
case 'kex.request':
if (onKexRequest == null || result.payload == null) break;
final resp = onKexRequest(result.payload!['keypair_id']);
if (resp == null) break;
channel.sink.add(jsonEncode(
NetworkPackage(method: 'kex.provide', payload: {
'request_id': result.payload!['request_id'],
'keypair_id': resp.id,
'public_key': resp.publicKey,
'algorithm': resp.algorithm,
}).toJson(),
));
break;
case 'kex.provide':
if (onKexProvide == null || result.payload == null) break;
onKexProvide(Keypair(
id: result.payload!['keypair_id'],
algorithm: result.payload?['algorithm'] ?? 'aes',
publicKey: result.payload!['public_key'],
privateKey: result.payload?['private_key'],
));
break;
} }
}, },
onError: (_, __) => connect(auth), onError: (_, __) => connect(auth),
onDone: () => connect(auth), onDone: () => connect(auth),
); );
}
void onRemoteMessage(model.Notification item) { return channel;
unreadAmount++;
notifications.add(item);
notifyListeners();
} }
void notifyMessage(String title, String body) { void notifyMessage(String title, String body) {
@ -136,7 +162,7 @@ class NotifyProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void clearRealtime() { void clearRealtimeNotifications() {
notifications = notifications.where((x) => !x.isRealtime).toList(); notifications = notifications.where((x) => !x.isRealtime).toList();
notifyListeners(); notifyListeners();
} }

View File

@ -6,6 +6,7 @@ import 'package:solian/models/post.dart';
import 'package:solian/models/realm.dart'; import 'package:solian/models/realm.dart';
import 'package:solian/screens/account.dart'; import 'package:solian/screens/account.dart';
import 'package:solian/screens/account/friend.dart'; import 'package:solian/screens/account/friend.dart';
import 'package:solian/screens/account/keypair.dart';
import 'package:solian/screens/account/personalize.dart'; import 'package:solian/screens/account/personalize.dart';
import 'package:solian/screens/auth/signup.dart'; import 'package:solian/screens/auth/signup.dart';
import 'package:solian/screens/chat/call.dart'; import 'package:solian/screens/chat/call.dart';
@ -187,7 +188,7 @@ abstract class SolianRouter {
name: 'chat.channel.editor', name: 'chat.channel.editor',
builder: (context, state) => ChannelEditorScreen( builder: (context, state) => ChannelEditorScreen(
editing: state.extra as Channel?, editing: state.extra as Channel?,
realm: state.uri.queryParameters['realm'], realm: state.uri.queryParameters['realm'] ?? 'global',
), ),
), ),
GoRoute( GoRoute(
@ -250,6 +251,11 @@ abstract class SolianRouter {
name: 'account.personalize', name: 'account.personalize',
builder: (context, state) => const PersonalizeScreen(), builder: (context, state) => const PersonalizeScreen(),
), ),
GoRoute(
path: '/account/keypair',
name: 'account.keypair',
builder: (context, state) => const KeypairScreen(),
),
], ],
), ),
GoRoute( GoRoute(

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/providers/keypair.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/utils/theme.dart'; import 'package:solian/utils/theme.dart';
import 'package:solian/widgets/account/account_avatar.dart'; import 'package:solian/widgets/account/account_avatar.dart';
@ -49,6 +50,13 @@ class _AccountScreenWidgetState extends State<AccountScreenWidget> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final auth = context.watch<AuthProvider>(); final auth = context.watch<AuthProvider>();
final keypair = context.read<KeypairProvider>();
final actionItems = [
(const Icon(Icons.color_lens), AppLocalizations.of(context)!.personalize, 'account.personalize'),
(const Icon(Icons.diversity_1), AppLocalizations.of(context)!.friend, 'account.friend'),
(const Icon(Icons.key), AppLocalizations.of(context)!.keypair, 'account.keypair'),
];
if (_isAuthorized) { if (_isAuthorized) {
return Column( return Column(
@ -57,28 +65,23 @@ class _AccountScreenWidgetState extends State<AccountScreenWidget> {
padding: EdgeInsets.only(top: 16, bottom: 8, left: 24, right: 24), padding: EdgeInsets.only(top: 16, bottom: 8, left: 24, right: 24),
child: NameCard(), child: NameCard(),
), ),
ListTile( ...(actionItems.map(
(x) => ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 34), contentPadding: const EdgeInsets.symmetric(horizontal: 34),
leading: const Icon(Icons.color_lens), leading: x.$1,
title: Text(AppLocalizations.of(context)!.personalize), title: Text(x.$2),
onTap: () { onTap: () {
widget.onSelect('account.personalize'); widget.onSelect(x.$3);
},
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 34),
leading: const Icon(Icons.diversity_1),
title: Text(AppLocalizations.of(context)!.friend),
onTap: () {
widget.onSelect('account.friend');
}, },
), ),
)),
ListTile( ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 34), contentPadding: const EdgeInsets.symmetric(horizontal: 34),
leading: const Icon(Icons.logout), leading: const Icon(Icons.logout),
title: Text(AppLocalizations.of(context)!.signOut), title: Text(AppLocalizations.of(context)!.signOut),
onTap: () { onTap: () {
auth.signoff(); auth.signoff();
keypair.clearKeys();
setState(() { setState(() {
_isAuthorized = false; _isAuthorized = false;
}); });

View File

@ -0,0 +1,191 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/keypair.dart';
import 'package:solian/providers/keypair.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class KeypairScreen extends StatelessWidget {
const KeypairScreen({super.key});
Widget getIcon(KeypairProvider provider, Keypair item) {
if (item.id == provider.activeKeyId) {
return const Icon(Icons.check_box);
} else if (item.isOwned) {
return const Icon(Icons.check_box_outlined);
} else {
return const Icon(Icons.key);
}
}
void importKeys(BuildContext context) async {
final controller = TextEditingController();
final input = await showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text(AppLocalizations.of(context)!.import),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(AppLocalizations.of(context)!.keypairImportHint),
const SizedBox(height: 18),
TextField(
controller: controller,
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: AppLocalizations.of(context)!.keypairSecretCode,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
],
),
actions: <Widget>[
TextButton(
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
),
onPressed: () => Navigator.pop(context),
child: Text(AppLocalizations.of(context)!.cancel),
),
TextButton(
child: Text(AppLocalizations.of(context)!.next),
onPressed: () {
Navigator.pop(context, controller.text);
},
),
],
);
},
);
WidgetsBinding.instance.addPostFrameCallback((_) => controller.dispose());
if (input == null || input.isEmpty) return;
context.read<KeypairProvider>().importKeys(input);
}
void exportKeys(BuildContext context) {
showModalBottomSheet(context: context, builder: (context) => const KeypairExportWidget());
}
@override
Widget build(BuildContext context) {
final keypair = context.watch<KeypairProvider>();
final keys = keypair.keys.values.toList();
return IndentScaffold(
title: AppLocalizations.of(context)!.keypair,
hideDrawer: true,
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.generating_tokens),
onPressed: () {
final result = keypair.generateAESKey();
keypair.setActiveKey(result.id);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context)!.keypairGenerated),
));
},
),
appBarActions: [
IconButton(
icon: const Icon(Icons.upload),
tooltip: AppLocalizations.of(context)!.import,
onPressed: () => importKeys(context),
),
IconButton(
icon: const Icon(Icons.download),
tooltip: AppLocalizations.of(context)!.export,
onPressed: () => exportKeys(context),
),
],
body: ListView.builder(
itemCount: keys.length,
itemBuilder: (context, index) {
final element = keys[index];
final randomId = DateTime.now().microsecondsSinceEpoch >> 10;
return Dismissible(
key: Key(randomId.toString()),
background: Container(
color: Colors.teal,
padding: const EdgeInsets.symmetric(horizontal: 20),
alignment: Alignment.centerLeft,
child: const Icon(Icons.check, color: Colors.white),
),
direction: keypair.activeKeyId != element.id && element.isOwned
? DismissDirection.horizontal
: DismissDirection.none,
child: ListTile(
leading: getIcon(keypair, element),
title: Text('${element.algorithm.toUpperCase()} Key'),
subtitle: Text(element.id.toUpperCase()),
),
onDismissed: (_) {
keypair.setActiveKey(element.id);
},
);
},
),
);
}
}
class KeypairExportWidget extends StatelessWidget {
const KeypairExportWidget({super.key});
String getEncodedContent(BuildContext context) {
final keypair = context.read<KeypairProvider>();
return utf8.fuse(base64).encode(jsonEncode(
keypair.keys.values.map((x) => x.toJson()).toList(),
));
}
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(32)),
child: SizedBox(
width: double.infinity,
height: 640,
child: ListView(
children: [
Container(
padding: const EdgeInsets.only(left: 20, top: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context)!.export,
style: Theme.of(context).textTheme.headlineSmall,
),
],
),
),
Padding(
padding: const EdgeInsets.only(left: 20, right: 20, top: 20, bottom: 20),
child: Text(
AppLocalizations.of(context)!.keypairExportHint,
style: Theme.of(context).textTheme.bodyMedium,
),
),
Markdown(
selectable: true,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
data: '```\n${getEncodedContent(context)}\n```',
padding: const EdgeInsets.all(0),
styleSheet: MarkdownStyleSheet(
codeblockPadding: const EdgeInsets.symmetric(vertical: 20, horizontal: 8),
),
),
],
),
),
);
}
}

View File

@ -15,9 +15,9 @@ import 'package:uuid/uuid.dart';
class ChannelEditorScreen extends StatefulWidget { class ChannelEditorScreen extends StatefulWidget {
final Channel? editing; final Channel? editing;
final String? realm; final String realm;
const ChannelEditorScreen({super.key, this.editing, this.realm}); const ChannelEditorScreen({super.key, this.editing, this.realm = 'global'});
@override @override
State<ChannelEditorScreen> createState() => _ChannelEditorScreenState(); State<ChannelEditorScreen> createState() => _ChannelEditorScreenState();
@ -28,6 +28,8 @@ class _ChannelEditorScreenState extends State<ChannelEditorScreen> {
final _nameController = TextEditingController(); final _nameController = TextEditingController();
final _descriptionController = TextEditingController(); final _descriptionController = TextEditingController();
bool _isEncrypted = false;
bool _isSubmitting = false; bool _isSubmitting = false;
Future<void> applyChannel(BuildContext context) async { Future<void> applyChannel(BuildContext context) async {
@ -39,9 +41,10 @@ class _ChannelEditorScreenState extends State<ChannelEditorScreen> {
return; return;
} }
final scope = widget.realm.isNotEmpty ? widget.realm : 'global';
final uri = widget.editing == null final uri = widget.editing == null
? getRequestUri('messaging', '/api/channels/${widget.realm ?? 'global'}') ? getRequestUri('messaging', '/api/channels/$scope')
: getRequestUri('messaging', '/api/channels/${widget.realm ?? 'global'}/${widget.editing!.id}'); : getRequestUri('messaging', '/api/channels/$scope/${widget.editing!.id}');
final req = Request(widget.editing == null ? 'POST' : 'PUT', uri); final req = Request(widget.editing == null ? 'POST' : 'PUT', uri);
req.headers['Content-Type'] = 'application/json'; req.headers['Content-Type'] = 'application/json';
@ -49,6 +52,7 @@ class _ChannelEditorScreenState extends State<ChannelEditorScreen> {
'alias': _aliasController.value.text.toLowerCase(), 'alias': _aliasController.value.text.toLowerCase(),
'name': _nameController.value.text, 'name': _nameController.value.text,
'description': _descriptionController.value.text, 'description': _descriptionController.value.text,
'is_encrypted': _isEncrypted,
}); });
var res = await Response.fromStream(await auth.client!.send(req)); var res = await Response.fromStream(await auth.client!.send(req));
@ -57,7 +61,7 @@ class _ChannelEditorScreenState extends State<ChannelEditorScreen> {
context.showErrorDialog(message); context.showErrorDialog(message);
} else { } else {
if (SolianRouter.router.canPop()) { if (SolianRouter.router.canPop()) {
SolianRouter.router.pop(true); SolianRouter.router.pop(_aliasController.value.text.toLowerCase());
} }
} }
setState(() => _isSubmitting = false); setState(() => _isSubmitting = false);
@ -79,6 +83,7 @@ class _ChannelEditorScreenState extends State<ChannelEditorScreen> {
_aliasController.text = widget.editing!.alias; _aliasController.text = widget.editing!.alias;
_nameController.text = widget.editing!.name; _nameController.text = widget.editing!.name;
_descriptionController.text = widget.editing!.description; _descriptionController.text = widget.editing!.description;
_isEncrypted = widget.editing!.isEncrypted;
} }
super.initState(); super.initState();
@ -177,6 +182,15 @@ class _ChannelEditorScreenState extends State<ChannelEditorScreen> {
), ),
), ),
), ),
const Divider(thickness: 0.3),
CheckboxListTile(
title: Text(AppLocalizations.of(context)!.chatChannelEncryptedLabel),
value: _isEncrypted,
onChanged: (widget.editing?.isEncrypted ?? false) ? null : (newValue) {
setState(() => _isEncrypted = newValue ?? false);
},
controlAffinity: ListTileControlAffinity.leading,
),
], ],
), ),
); );

View File

@ -255,6 +255,7 @@ class _ChatWidgetState extends State<ChatWidget> {
channel: widget.alias, channel: widget.alias,
editing: _editingItem, editing: _editingItem,
replying: _replyingItem, replying: _replyingItem,
isEncrypted: _chat.focusChannel?.isEncrypted ?? false,
onReset: () => setState(() { onReset: () => setState(() {
_editingItem = null; _editingItem = null;
_replyingItem = null; _replyingItem = null;

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:solian/models/channel.dart'; import 'package:solian/models/channel.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/providers/chat.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/widgets/chat/channel_deletion.dart'; import 'package:solian/widgets/chat/channel_deletion.dart';
import 'package:solian/widgets/scaffold.dart'; import 'package:solian/widgets/scaffold.dart';
@ -23,7 +24,8 @@ class _ChatDetailScreenState extends State<ChatDetailScreen> {
void promptLeaveChannel() async { void promptLeaveChannel() async {
final did = await showDialog( final did = await showDialog(
context: context, context: context,
builder: (context) => ChannelDeletion( builder: (context) =>
ChannelDeletion(
channel: widget.channel, channel: widget.channel,
realm: widget.realm, realm: widget.realm,
isOwned: _isOwned, isOwned: _isOwned,
@ -50,14 +52,23 @@ class _ChatDetailScreenState extends State<ChatDetailScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final auth = context.read<AuthProvider>();
final chat = context.read<ChatProvider>();
final authorizedItems = [ final authorizedItems = [
ListTile( ListTile(
leading: const Icon(Icons.settings), leading: const Icon(Icons.settings),
title: Text(AppLocalizations.of(context)!.settings), title: Text(AppLocalizations.of(context)!.settings),
onTap: () async { onTap: () async {
SolianRouter.router.pushNamed('chat.channel.editor', extra: widget.channel).then((did) { SolianRouter.router
if (did == true) { .pushNamed(
if (SolianRouter.router.canPop()) SolianRouter.router.pop('refresh'); 'chat.channel.editor',
extra: widget.channel,
queryParameters: widget.realm != 'global' ? {'realm': widget.realm} : {},
)
.then((resp) {
if (resp != null) {
chat.fetchChannel(context, auth, resp as String, widget.realm);
} }
}); });
}, },
@ -81,8 +92,14 @@ class _ChatDetailScreenState extends State<ChatDetailScreen> {
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(widget.channel.name, style: Theme.of(context).textTheme.bodyLarge), Text(widget.channel.name, style: Theme
Text(widget.channel.description, style: Theme.of(context).textTheme.bodySmall), .of(context)
.textTheme
.bodyLarge),
Text(widget.channel.description, style: Theme
.of(context)
.textTheme
.bodySmall),
]), ]),
) )
], ],

View File

@ -124,11 +124,10 @@ class _ChatListWidgetState extends State<ChatListWidget> {
title: Text(element.name), title: Text(element.name),
subtitle: Text(element.description), subtitle: Text(element.description),
onTap: () async { onTap: () async {
String? result;
if (['chat.channel', 'realms.chat.channel'].contains(SolianRouter.currentRoute.name)) { if (['chat.channel', 'realms.chat.channel'].contains(SolianRouter.currentRoute.name)) {
chat.fetchChannel(context, auth, element.alias, widget.realm!); chat.fetchChannel(context, auth, element.alias, widget.realm!);
} else { } else {
result = await SolianRouter.router.pushNamed( SolianRouter.router.pushNamed(
widget.realm == null ? 'chat.channel' : 'realms.chat.channel', widget.realm == null ? 'chat.channel' : 'realms.chat.channel',
pathParameters: { pathParameters: {
'channel': element.alias, 'channel': element.alias,
@ -136,10 +135,6 @@ class _ChatListWidgetState extends State<ChatListWidget> {
}, },
); );
} }
switch (result) {
case 'refresh':
fetchChannels();
}
}, },
); );
}, },

View File

@ -37,7 +37,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
markList.add(element.id); markList.add(element.id);
} }
nty.clearRealtime(); nty.clearRealtimeNotifications();
if(markList.isNotEmpty) { if(markList.isNotEmpty) {
var uri = getRequestUri('passport', '/api/notifications/batch/read'); var uri = getRequestUri('passport', '/api/notifications/batch/read');

View File

@ -1,18 +1,97 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/message.dart'; import 'package:solian/models/message.dart';
import 'package:solian/providers/keypair.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class ChatMessageContent extends StatelessWidget { class ChatMessageContent extends StatefulWidget {
final Message item; final Message item;
const ChatMessageContent({super.key, required this.item}); const ChatMessageContent({super.key, required this.item});
@override
State<ChatMessageContent> createState() => _ChatMessageContentState();
}
class _ChatMessageContentState extends State<ChatMessageContent> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (item.type == 'm.text') { final feColor = Theme.of(context).colorScheme.onSurface.withOpacity(0.65);
final waitingKeyHint = Row(
children: [
Icon(Icons.key, color: feColor, size: 16),
const SizedBox(width: 4),
Expanded(
child: Text(
AppLocalizations.of(context)!.chatMessageUnableDecryptWaiting,
style: TextStyle(color: feColor),
),
),
],
);
final missingKeyHint = Row(
children: [
Icon(Icons.key_off_outlined, color: feColor, size: 16),
const SizedBox(width: 4),
Expanded(
child: Text(
AppLocalizations.of(context)!.chatMessageUnableDecryptMissing,
style: TextStyle(color: feColor),
),
),
],
);
if (widget.item.type == 'm.text') {
String? content;
switch (widget.item.decodedContent['algorithm']) {
case 'plain':
content = widget.item.decodedContent['value'];
case 'aes':
final keypair = context.watch<KeypairProvider>();
if (keypair.keys[widget.item.decodedContent['keypair_id']] == null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (keypair.requestKey(
widget.item.decodedContent['keypair_id'],
widget.item.decodedContent['algorithm'],
widget.item.sender.account.externalId!,
)) {
}
});
} else {
content = keypair.decodeViaAESKey(
widget.item.decodedContent['keypair_id'],
widget.item.decodedContent['value'],
)!;
break;
}
if (keypair.requestingKeys.contains(widget.item.decodedContent['keypair_id'])) {
return waitingKeyHint.animate().swap(builder: (context, _) {
return missingKeyHint;
}, delay: 3000.ms);
}
}
if (content == null) {
return Row(
children: [
Icon(Icons.key_off, color: feColor, size: 16),
const SizedBox(width: 4),
Text(
AppLocalizations.of(context)!.chatMessageUnableDecryptUnsupported,
style: TextStyle(color: feColor),
),
],
);
}
return Markdown( return Markdown(
data: item.decodedContent['value'], data: content,
shrinkWrap: true, shrinkWrap: true,
selectable: true, selectable: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),

View File

@ -9,6 +9,7 @@ import 'package:provider/provider.dart';
import 'package:solian/models/message.dart'; import 'package:solian/models/message.dart';
import 'package:solian/models/post.dart'; import 'package:solian/models/post.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/providers/keypair.dart';
import 'package:solian/utils/services_url.dart'; import 'package:solian/utils/services_url.dart';
import 'package:solian/widgets/exts.dart'; import 'package:solian/widgets/exts.dart';
import 'package:solian/widgets/posts/attachment_editor.dart'; import 'package:solian/widgets/posts/attachment_editor.dart';
@ -19,11 +20,13 @@ class ChatMessageEditor extends StatefulWidget {
final String realm; final String realm;
final Message? editing; final Message? editing;
final Message? replying; final Message? replying;
final bool isEncrypted;
final Function? onReset; final Function? onReset;
const ChatMessageEditor({ const ChatMessageEditor({
super.key, super.key,
required this.channel, required this.channel,
required this.isEncrypted,
this.realm = 'global', this.realm = 'global',
this.editing, this.editing,
this.replying, this.replying,
@ -55,8 +58,19 @@ class _ChatMessageEditorState extends State<ChatMessageEditor> {
); );
} }
Map<String, dynamic> buildContentBody(String content, {String algorithm = 'plain'}) { Map<String, dynamic> buildContentBody(String content) {
return {'value': content, 'algorithm': algorithm}; final keypair = context.read<KeypairProvider>();
if (keypair.activeKeyId == null || keypair.keys[keypair.activeKeyId] == null) {
final kp = keypair.generateAESKey();
keypair.setActiveKey(kp.id);
return buildContentBody(content);
}
return {
'value': widget.isEncrypted ? keypair.encodeViaAESKey(keypair.activeKeyId!, content) : content,
'keypair_id': widget.isEncrypted ? keypair.activeKeyId : null,
'algorithm': widget.isEncrypted ? 'aes' : 'plain',
};
} }
Future<void> sendMessage(BuildContext context) async { Future<void> sendMessage(BuildContext context) async {
@ -214,7 +228,9 @@ class _ChatMessageEditorState extends State<ChatMessageEditor> {
autocorrect: true, autocorrect: true,
keyboardType: TextInputType.text, keyboardType: TextInputType.text,
decoration: InputDecoration.collapsed( decoration: InputDecoration.collapsed(
hintText: AppLocalizations.of(context)!.chatMessagePlaceholder, hintText: widget.isEncrypted
? AppLocalizations.of(context)!.chatMessageEncryptedPlaceholder
: AppLocalizations.of(context)!.chatMessagePlaceholder,
), ),
onSubmitted: (_) => sendMessage(context), onSubmitted: (_) => sendMessage(context),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),

View File

@ -1,63 +1,12 @@
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:solian/models/packet.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/providers/keypair.dart';
import 'package:solian/providers/notify.dart'; import 'package:solian/providers/notify.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/models/notification.dart' as model;
import 'package:badges/badges.dart' as badge; import 'package:badges/badges.dart' as badge;
class NotificationNotifier extends StatefulWidget {
final Widget child;
const NotificationNotifier({super.key, required this.child});
@override
State<NotificationNotifier> createState() => _NotificationNotifierState();
}
class _NotificationNotifierState extends State<NotificationNotifier> {
void connect() async {
final notify = ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.connectingServer),
duration: const Duration(minutes: 1),
),
);
final auth = context.read<AuthProvider>();
final nty = context.read<NotifyProvider>();
if (await auth.isAuthorized()) {
if (auth.client == null) {
await auth.loadClient();
}
nty.fetch(auth);
nty.connect(auth);
}
notify.close();
}
@override
void initState() {
Future.delayed(Duration.zero, () {
connect();
});
super.initState();
}
@override
Widget build(BuildContext context) {
return widget.child;
}
}
class NotificationButton extends StatefulWidget { class NotificationButton extends StatefulWidget {
const NotificationButton({super.key}); const NotificationButton({super.key});
@ -66,6 +15,43 @@ class NotificationButton extends StatefulWidget {
} }
class _NotificationButtonState extends State<NotificationButton> { class _NotificationButtonState extends State<NotificationButton> {
void connect() async {
final auth = context.read<AuthProvider>();
final nty = context.read<NotifyProvider>();
final keypair = context.read<KeypairProvider>();
if (nty.isOpened) return;
final notify = ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.connectingServer),
duration: const Duration(minutes: 1),
),
);
if (await auth.isAuthorized()) {
if (auth.client == null) {
await auth.loadClient();
}
nty.fetch(auth);
keypair.channel = await nty.connect(
auth,
onKexRequest: keypair.provideKeypair,
onKexProvide: keypair.receiveKeypair,
);
}
notify.close();
}
@override
void initState() {
super.initState();
Future.delayed(Duration.zero, () => connect());
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final nty = context.watch<NotifyProvider>(); final nty = context.watch<NotifyProvider>();

View File

@ -21,10 +21,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: asn1lib name: asn1lib
sha256: c9c85fedbe2188b95133cbe960e16f5f448860f7133330e272edbbca5893ddc6 sha256: "58082b3f0dca697204dbab0ef9ff208bfaea7767ea771076af9a343488428dda"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.5.2" version: "1.5.3"
async: async:
dependency: transitive dependency: transitive
description: description:
@ -41,6 +41,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.2" version: "3.1.2"
basic_utils:
dependency: "direct main"
description:
name: basic_utils
sha256: "2064b21d3c41ed7654bc82cc476fd65542e04d60059b74d5eed490a4da08fc6c"
url: "https://pub.dev"
source: hosted
version: "5.7.0"
boolean_selector: boolean_selector:
dependency: transitive dependency: transitive
description: description:
@ -985,10 +993,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: pointycastle name: pointycastle
sha256: "79fbafed02cfdbe85ef3fd06c7f4bc2cbcba0177e61b765264853d4253b21744" sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.9.0" version: "3.9.1"
protobuf: protobuf:
dependency: transitive dependency: transitive
description: description:

View File

@ -72,8 +72,9 @@ dependencies:
cached_network_image: ^3.3.1 cached_network_image: ^3.3.1
desktop_drop: ^0.4.4 desktop_drop: ^0.4.4
easy_debounce: ^2.0.3 easy_debounce: ^2.0.3
encrypt: ^5.0.3
google_fonts: ^6.2.1 google_fonts: ^6.2.1
basic_utils: ^5.7.0
encrypt: ^5.0.3
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: