E2EE and Keypair

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

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/keypair.dart';
import 'package:solian/router.dart';
import 'package:solian/utils/theme.dart';
import 'package:solian/widgets/account/account_avatar.dart';
@ -49,6 +50,13 @@ class _AccountScreenWidgetState extends State<AccountScreenWidget> {
@override
Widget build(BuildContext context) {
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) {
return Column(
@ -57,28 +65,23 @@ class _AccountScreenWidgetState extends State<AccountScreenWidget> {
padding: EdgeInsets.only(top: 16, bottom: 8, left: 24, right: 24),
child: NameCard(),
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 34),
leading: const Icon(Icons.color_lens),
title: Text(AppLocalizations.of(context)!.personalize),
onTap: () {
widget.onSelect('account.personalize');
},
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 34),
leading: const Icon(Icons.diversity_1),
title: Text(AppLocalizations.of(context)!.friend),
onTap: () {
widget.onSelect('account.friend');
},
),
...(actionItems.map(
(x) => ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 34),
leading: x.$1,
title: Text(x.$2),
onTap: () {
widget.onSelect(x.$3);
},
),
)),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 34),
leading: const Icon(Icons.logout),
title: Text(AppLocalizations.of(context)!.signOut),
onTap: () {
auth.signoff();
keypair.clearKeys();
setState(() {
_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 {
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
State<ChannelEditorScreen> createState() => _ChannelEditorScreenState();
@ -28,6 +28,8 @@ class _ChannelEditorScreenState extends State<ChannelEditorScreen> {
final _nameController = TextEditingController();
final _descriptionController = TextEditingController();
bool _isEncrypted = false;
bool _isSubmitting = false;
Future<void> applyChannel(BuildContext context) async {
@ -39,9 +41,10 @@ class _ChannelEditorScreenState extends State<ChannelEditorScreen> {
return;
}
final scope = widget.realm.isNotEmpty ? widget.realm : 'global';
final uri = widget.editing == null
? getRequestUri('messaging', '/api/channels/${widget.realm ?? 'global'}')
: getRequestUri('messaging', '/api/channels/${widget.realm ?? 'global'}/${widget.editing!.id}');
? getRequestUri('messaging', '/api/channels/$scope')
: getRequestUri('messaging', '/api/channels/$scope/${widget.editing!.id}');
final req = Request(widget.editing == null ? 'POST' : 'PUT', uri);
req.headers['Content-Type'] = 'application/json';
@ -49,6 +52,7 @@ class _ChannelEditorScreenState extends State<ChannelEditorScreen> {
'alias': _aliasController.value.text.toLowerCase(),
'name': _nameController.value.text,
'description': _descriptionController.value.text,
'is_encrypted': _isEncrypted,
});
var res = await Response.fromStream(await auth.client!.send(req));
@ -57,7 +61,7 @@ class _ChannelEditorScreenState extends State<ChannelEditorScreen> {
context.showErrorDialog(message);
} else {
if (SolianRouter.router.canPop()) {
SolianRouter.router.pop(true);
SolianRouter.router.pop(_aliasController.value.text.toLowerCase());
}
}
setState(() => _isSubmitting = false);
@ -79,6 +83,7 @@ class _ChannelEditorScreenState extends State<ChannelEditorScreen> {
_aliasController.text = widget.editing!.alias;
_nameController.text = widget.editing!.name;
_descriptionController.text = widget.editing!.description;
_isEncrypted = widget.editing!.isEncrypted;
}
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,
editing: _editingItem,
replying: _replyingItem,
isEncrypted: _chat.focusChannel?.isEncrypted ?? false,
onReset: () => setState(() {
_editingItem = null;
_replyingItem = null;

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/chat.dart';
import 'package:solian/router.dart';
import 'package:solian/widgets/chat/channel_deletion.dart';
import 'package:solian/widgets/scaffold.dart';
@ -23,11 +24,12 @@ class _ChatDetailScreenState extends State<ChatDetailScreen> {
void promptLeaveChannel() async {
final did = await showDialog(
context: context,
builder: (context) => ChannelDeletion(
channel: widget.channel,
realm: widget.realm,
isOwned: _isOwned,
),
builder: (context) =>
ChannelDeletion(
channel: widget.channel,
realm: widget.realm,
isOwned: _isOwned,
),
);
if (did == true && SolianRouter.router.canPop()) {
SolianRouter.router.pop('disposed');
@ -50,14 +52,23 @@ class _ChatDetailScreenState extends State<ChatDetailScreen> {
@override
Widget build(BuildContext context) {
final auth = context.read<AuthProvider>();
final chat = context.read<ChatProvider>();
final authorizedItems = [
ListTile(
leading: const Icon(Icons.settings),
title: Text(AppLocalizations.of(context)!.settings),
onTap: () async {
SolianRouter.router.pushNamed('chat.channel.editor', extra: widget.channel).then((did) {
if (did == true) {
if (SolianRouter.router.canPop()) SolianRouter.router.pop('refresh');
SolianRouter.router
.pushNamed(
'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),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(widget.channel.name, style: Theme.of(context).textTheme.bodyLarge),
Text(widget.channel.description, style: Theme.of(context).textTheme.bodySmall),
Text(widget.channel.name, style: Theme
.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),
subtitle: Text(element.description),
onTap: () async {
String? result;
if (['chat.channel', 'realms.chat.channel'].contains(SolianRouter.currentRoute.name)) {
chat.fetchChannel(context, auth, element.alias, widget.realm!);
} else {
result = await SolianRouter.router.pushNamed(
SolianRouter.router.pushNamed(
widget.realm == null ? 'chat.channel' : 'realms.chat.channel',
pathParameters: {
'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);
}
nty.clearRealtime();
nty.clearRealtimeNotifications();
if(markList.isNotEmpty) {
var uri = getRequestUri('passport', '/api/notifications/batch/read');