Add Cross-Signing

This commit is contained in:
Sorunome 2020-06-25 14:29:06 +00:00 committed by Christian Pauly
commit c4a33e411a
13 changed files with 1149 additions and 74 deletions

View file

@ -1,4 +1,5 @@
import 'package:famedlysdk/famedlysdk.dart';
import 'package:famedlysdk/encryption.dart';
import 'package:fluffychat/components/adaptive_page_layout.dart';
import 'package:fluffychat/components/avatar.dart';
import 'package:fluffychat/components/matrix.dart';
@ -6,6 +7,9 @@ import 'package:fluffychat/utils/beautify_string_extension.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/views/chat_list.dart';
import 'package:flutter/material.dart';
import 'key_verification.dart';
import '../utils/app_route.dart';
import '../components/dialogs/simple_dialogs.dart';
class ChatEncryptionSettingsView extends StatelessWidget {
final String id;
@ -33,6 +37,70 @@ class ChatEncryptionSettings extends StatefulWidget {
}
class _ChatEncryptionSettingsState extends State<ChatEncryptionSettings> {
Future<void> onSelected(
BuildContext context, String action, DeviceKeys key) async {
final room = Matrix.of(context).client.getRoomById(widget.id);
final unblock = () async {
if (key.blocked) {
await key.setBlocked(false);
}
};
switch (action) {
case 'verify':
await unblock();
final req = key.startVerification();
req.onUpdate = () {
if (req.state == KeyVerificationState.done) {
setState(() => null);
}
};
await Navigator.of(context).push(
AppRoute.defaultRoute(
context,
KeyVerificationView(request: req),
),
);
break;
case 'verify_manual':
if (await SimpleDialogs(context).askConfirmation(
titleText: L10n.of(context).isDeviceKeyCorrect,
contentText: key.ed25519Key.beautified,
)) {
await unblock();
await key.setVerified(true);
setState(() => null);
}
break;
case 'verify_user':
await unblock();
final req =
await room.client.userDeviceKeys[key.userId].startVerification();
req.onUpdate = () {
if (req.state == KeyVerificationState.done) {
setState(() => null);
}
};
await Navigator.of(context).push(
AppRoute.defaultRoute(
context,
KeyVerificationView(request: req),
),
);
break;
case 'block':
if (key.directVerified) {
await key.setVerified(false);
}
await key.setBlocked(true);
setState(() => null);
break;
case 'unblock':
await unblock();
setState(() => null);
break;
}
}
@override
Widget build(BuildContext context) {
final room = Matrix.of(context).client.getRoomById(widget.id);
@ -68,59 +136,99 @@ class _ChatEncryptionSettingsState extends State<ChatEncryptionSettings> {
if (i == 0 ||
deviceKeys[i].userId != deviceKeys[i - 1].userId)
Material(
child: ListTile(
leading: Avatar(
room
child: PopupMenuButton(
onSelected: (action) =>
onSelected(context, action, deviceKeys[i]),
itemBuilder: (c) {
var items = <PopupMenuEntry<String>>[];
if (room
.client
.userDeviceKeys[deviceKeys[i].userId]
.verified ==
UserVerifiedStatus.unknown &&
deviceKeys[i].userId != room.client.userID) {
items.add(PopupMenuItem(
child: Text(L10n.of(context).verifyUser),
value: 'verify_user',
));
}
return items;
},
child: ListTile(
leading: Avatar(
room
.getUserByMXIDSync(deviceKeys[i].userId)
.avatarUrl,
room
.getUserByMXIDSync(deviceKeys[i].userId)
.calcDisplayname(),
),
title: Text(room
.getUserByMXIDSync(deviceKeys[i].userId)
.avatarUrl,
room
.getUserByMXIDSync(deviceKeys[i].userId)
.calcDisplayname(),
.calcDisplayname()),
subtitle: Text(deviceKeys[i].userId),
),
title: Text(room
.getUserByMXIDSync(deviceKeys[i].userId)
.calcDisplayname()),
subtitle: Text(deviceKeys[i].userId),
),
elevation: 2,
),
CheckboxListTile(
title: Text(
"${deviceKeys[i].unsigned["device_display_name"] ?? L10n.of(context).unknownDevice} - ${deviceKeys[i].deviceId}",
style: TextStyle(
color: deviceKeys[i].blocked
? Colors.red
: deviceKeys[i].verified
? Colors.green
: Colors.orange),
),
subtitle: Text(
deviceKeys[i]
.keys['ed25519:${deviceKeys[i].deviceId}']
.beautified,
style: TextStyle(
color:
Theme.of(context).textTheme.bodyText2.color),
),
value: deviceKeys[i].verified,
onChanged: (bool newVal) {
if (newVal == true) {
if (deviceKeys[i].blocked) {
deviceKeys[i]
.setBlocked(false, Matrix.of(context).client);
PopupMenuButton(
onSelected: (action) =>
onSelected(context, action, deviceKeys[i]),
itemBuilder: (c) {
var items = <PopupMenuEntry<String>>[];
if (deviceKeys[i].blocked ||
!deviceKeys[i].verified) {
if (deviceKeys[i].userId == room.client.userID) {
items.add(PopupMenuItem(
child: Text(L10n.of(context).verifyStart),
value: 'verify',
));
items.add(PopupMenuItem(
child: Text(L10n.of(context).verifyManual),
value: 'verify_manual',
));
} else {
items.add(PopupMenuItem(
child: Text(L10n.of(context).verifyUser),
value: 'verify_user',
));
}
deviceKeys[i]
.setVerified(true, Matrix.of(context).client);
} else {
if (deviceKeys[i].verified) {
deviceKeys[i].setVerified(
false, Matrix.of(context).client);
}
deviceKeys[i]
.setBlocked(true, Matrix.of(context).client);
}
setState(() => null);
if (deviceKeys[i].blocked) {
items.add(PopupMenuItem(
child: Text(L10n.of(context).unblockDevice),
value: 'unblock',
));
}
if (!deviceKeys[i].blocked) {
items.add(PopupMenuItem(
child: Text(L10n.of(context).blockDevice),
value: 'block',
));
}
return items;
},
child: ListTile(
title: Text(
"${deviceKeys[i].unsigned["device_display_name"] ?? L10n.of(context).unknownDevice} - ${deviceKeys[i].deviceId}",
style: TextStyle(
color: deviceKeys[i].blocked
? Colors.red
: deviceKeys[i].verified
? Colors.green
: Colors.orange),
),
subtitle: Text(
deviceKeys[i]
.keys['ed25519:${deviceKeys[i].deviceId}']
.beautified,
style: TextStyle(
color: Theme.of(context)
.textTheme
.bodyText2
.color),
),
),
),
],
),

View file

@ -0,0 +1,347 @@
import 'package:flutter/material.dart';
import 'package:famedlysdk/encryption.dart';
import 'package:famedlysdk/matrix_api.dart';
import 'chat_list.dart';
import '../components/adaptive_page_layout.dart';
import '../components/avatar.dart';
import '../components/dialogs/simple_dialogs.dart';
import '../l10n/l10n.dart';
class KeyVerificationView extends StatelessWidget {
final KeyVerification request;
KeyVerificationView({this.request});
@override
Widget build(BuildContext context) {
return AdaptivePageLayout(
primaryPage: FocusPage.SECOND,
firstScaffold: ChatList(),
secondScaffold: KeyVerificationPage(request: request),
);
}
}
class KeyVerificationPage extends StatefulWidget {
final KeyVerification request;
KeyVerificationPage({this.request});
@override
_KeyVerificationPageState createState() => _KeyVerificationPageState();
}
class _KeyVerificationPageState extends State<KeyVerificationPage> {
void Function() originalOnUpdate;
@override
void initState() {
originalOnUpdate = widget.request.onUpdate;
widget.request.onUpdate = () {
if (originalOnUpdate != null) {
originalOnUpdate();
}
setState(() => null);
};
widget.request.client.getProfileFromUserId(widget.request.userId).then((p) {
profile = p;
setState(() => null);
});
super.initState();
}
@override
void dispose() {
widget.request.onUpdate =
originalOnUpdate; // don't want to get updates anymore
if (![KeyVerificationState.error, KeyVerificationState.done]
.contains(widget.request.state)) {
widget.request.cancel('m.user');
}
super.dispose();
}
Profile profile;
@override
Widget build(BuildContext context) {
Widget body;
final buttons = <Widget>[];
switch (widget.request.state) {
case KeyVerificationState.askSSSS:
// prompt the user for their ssss passphrase / key
final textEditingController = TextEditingController();
String input;
final checkInput = () async {
if (input == null) {
return;
}
SimpleDialogs(context).showLoadingDialog(context);
// make sure the loading spinner shows before we test the keys
await Future.delayed(Duration(milliseconds: 100));
var valid = false;
try {
await widget.request.openSSSS(recoveryKey: input);
valid = true;
} catch (_) {
try {
await widget.request.openSSSS(passphrase: input);
valid = true;
} catch (_) {
valid = false;
}
}
await Navigator.of(context)?.pop();
if (!valid) {
await SimpleDialogs(context).inform(
contentText: L10n.of(context).incorrectPassphraseOrKey,
);
}
};
body = Container(
margin: EdgeInsets.only(left: 8.0, right: 8.0),
child: Column(
children: <Widget>[
Text(L10n.of(context).askSSSSSign,
style: TextStyle(fontSize: 20)),
Container(height: 10),
TextField(
controller: textEditingController,
autofocus: false,
autocorrect: false,
onSubmitted: (s) {
input = s;
checkInput();
},
minLines: 1,
maxLines: 1,
obscureText: true,
decoration: InputDecoration(
hintText: L10n.of(context).passphraseOrKey,
prefixStyle: TextStyle(color: Theme.of(context).primaryColor),
suffixStyle: TextStyle(color: Theme.of(context).primaryColor),
border: OutlineInputBorder(),
),
),
],
mainAxisSize: MainAxisSize.min,
),
);
buttons.add(RaisedButton(
color: Theme.of(context).primaryColor,
elevation: 5,
textColor: Colors.white,
child: Text(L10n.of(context).submit),
onPressed: () {
input = textEditingController.text;
checkInput();
},
));
buttons.add(RaisedButton(
textColor: Theme.of(context).primaryColor,
elevation: 5,
color: Colors.white,
child: Text(L10n.of(context).skip),
onPressed: () => widget.request.openSSSS(skip: true),
));
break;
case KeyVerificationState.askAccept:
body = Container(
child: Text(
L10n.of(context).askVerificationRequest(widget.request.userId),
style: TextStyle(fontSize: 20)),
margin: EdgeInsets.only(left: 8.0, right: 8.0),
);
buttons.add(RaisedButton(
color: Theme.of(context).primaryColor,
elevation: 5,
textColor: Colors.white,
child: Text(L10n.of(context).accept),
onPressed: () => widget.request.acceptVerification(),
));
buttons.add(RaisedButton(
textColor: Theme.of(context).primaryColor,
elevation: 5,
color: Colors.white,
child: Text(L10n.of(context).reject),
onPressed: () {
widget.request.rejectVerification().then((_) {
Navigator.of(context).pop();
});
},
));
break;
case KeyVerificationState.waitingAccept:
body = Column(
children: <Widget>[
CircularProgressIndicator(),
Container(height: 10),
Text(L10n.of(context).waitingPartnerAcceptRequest),
],
mainAxisSize: MainAxisSize.min,
);
break;
case KeyVerificationState.askSas:
var emojiWidgets = <Widget>[];
// maybe add a button to switch between the two and only determine default
// view for if "emoji" is a present sasType or not?
String compareText;
if (widget.request.sasTypes.contains('emoji')) {
compareText = L10n.of(context).compareEmojiMatch;
emojiWidgets =
widget.request.sasEmojis.map((e) => _Emoji(e)).toList();
} else {
compareText = L10n.of(context).compareNumbersMatch;
final numbers = widget.request.sasNumbers;
emojiWidgets = <Widget>[
Text(numbers[0].toString(), style: TextStyle(fontSize: 40)),
Text('-', style: TextStyle(fontSize: 40)),
Text(numbers[1].toString(), style: TextStyle(fontSize: 40)),
Text('-', style: TextStyle(fontSize: 40)),
Text(numbers[2].toString(), style: TextStyle(fontSize: 40)),
];
}
body = Column(
children: <Widget>[
Container(
child: Text(compareText, style: TextStyle(fontSize: 20)),
margin: EdgeInsets.only(left: 8.0, right: 8.0),
),
Container(height: 10),
RichText(
text: TextSpan(
children:
emojiWidgets.map((w) => WidgetSpan(child: w)).toList(),
),
textAlign: TextAlign.center,
),
],
mainAxisSize: MainAxisSize.min,
);
buttons.add(RaisedButton(
color: Theme.of(context).primaryColor,
elevation: 5,
textColor: Colors.white,
child: Text(L10n.of(context).theyMatch),
onPressed: () => widget.request.acceptSas(),
));
buttons.add(RaisedButton(
textColor: Theme.of(context).primaryColor,
elevation: 5,
color: Colors.white,
child: Text(L10n.of(context).theyDontMatch),
onPressed: () => widget.request.rejectSas(),
));
break;
case KeyVerificationState.waitingSas:
var acceptText = widget.request.sasTypes.contains('emoji')
? L10n.of(context).waitingPartnerEmoji
: L10n.of(context).waitingPartnerNumbers;
body = Column(
children: <Widget>[
CircularProgressIndicator(),
Container(height: 10),
Text(acceptText),
],
mainAxisSize: MainAxisSize.min,
);
break;
case KeyVerificationState.done:
body = Column(
children: <Widget>[
Icon(Icons.check_circle, color: Colors.green, size: 200.0),
Container(height: 10),
Text(L10n.of(context).verifySuccess),
],
mainAxisSize: MainAxisSize.min,
);
buttons.add(RaisedButton(
color: Theme.of(context).primaryColor,
elevation: 5,
textColor: Colors.white,
child: Text(L10n.of(context).close),
onPressed: () => Navigator.of(context).pop(),
));
break;
case KeyVerificationState.error:
body = Column(
children: <Widget>[
Icon(Icons.cancel, color: Colors.red, size: 200.0),
Container(height: 10),
Text(
'Error ${widget.request.canceledCode}: ${widget.request.canceledReason}'),
],
mainAxisSize: MainAxisSize.min,
);
buttons.add(RaisedButton(
color: Theme.of(context).primaryColor,
elevation: 5,
textColor: Colors.white,
child: Text(L10n.of(context).close),
onPressed: () => Navigator.of(context).pop(),
));
break;
}
body ??= Text('ERROR: Unknown state ' + widget.request.state.toString());
final otherName = profile?.displayname ?? widget.request.userId;
var bottom;
if (widget.request.deviceId != null) {
final deviceName = widget
.request
.client
.userDeviceKeys[widget.request.userId]
?.deviceKeys[widget.request.deviceId]
?.deviceDisplayName ??
'';
bottom = PreferredSize(
child: Text('$deviceName (${widget.request.deviceId})',
style: TextStyle(color: Theme.of(context).textTheme.caption.color)),
preferredSize: Size(0.0, 20.0),
);
}
return Scaffold(
appBar: AppBar(
title: ListTile(
leading: Avatar(profile?.avatarUrl, otherName),
contentPadding: EdgeInsets.zero,
title: Text(L10n.of(context).verifyTitle),
isThreeLine: otherName != widget.request.userId,
subtitle: Column(
children: <Widget>[
Text(otherName),
if (otherName != widget.request.userId)
Text(widget.request.userId),
],
crossAxisAlignment: CrossAxisAlignment.start,
),
),
elevation: 0,
bottom: bottom,
),
extendBody: true,
extendBodyBehindAppBar: true,
body: Center(
child: body,
),
persistentFooterButtons: buttons.isEmpty ? null : buttons,
);
}
}
class _Emoji extends StatelessWidget {
final KeyVerificationEmoji emoji;
_Emoji(this.emoji);
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(emoji.emoji, style: TextStyle(fontSize: 50)),
Text(emoji.name),
Container(height: 10, width: 5),
],
);
}
}

View file

@ -38,6 +38,10 @@ class Settings extends StatefulWidget {
class _SettingsState extends State<Settings> {
Future<dynamic> profileFuture;
dynamic profile;
Future<bool> crossSigningCachedFuture;
bool crossSigningCached;
Future<bool> megolmBackupCachedFuture;
bool megolmBackupCached;
void logoutAction(BuildContext context) async {
if (await SimpleDialogs(context).askConfirmation() == false) {
@ -123,12 +127,65 @@ class _SettingsState extends State<Settings> {
setState(() => null);
}
Future<void> requestSSSSCache(BuildContext context) async {
final handle = Matrix.of(context).client.encryption.ssss.open();
final str = await SimpleDialogs(context).enterText(
titleText: L10n.of(context).askSSSSCache,
hintText: L10n.of(context).passphraseOrKey,
password: true,
);
if (str != null) {
SimpleDialogs(context).showLoadingDialog(context);
// make sure the loading spinner shows before we test the keys
await Future.delayed(Duration(milliseconds: 100));
var valid = false;
try {
handle.unlock(recoveryKey: str);
valid = true;
} catch (_) {
try {
handle.unlock(passphrase: str);
valid = true;
} catch (_) {
valid = false;
}
}
await Navigator.of(context)?.pop();
if (valid) {
await handle.maybeCacheAll();
await SimpleDialogs(context).inform(
contentText: L10n.of(context).cachedKeys,
);
setState(() {
crossSigningCachedFuture = null;
crossSigningCached = null;
megolmBackupCachedFuture = null;
megolmBackupCached = null;
});
} else {
await SimpleDialogs(context).inform(
contentText: L10n.of(context).incorrectPassphraseOrKey,
);
}
}
}
@override
Widget build(BuildContext context) {
final client = Matrix.of(context).client;
profileFuture ??= client.ownProfile;
profileFuture.then((p) {
profileFuture ??= client.ownProfile.then((p) {
if (mounted) setState(() => profile = p);
return p;
});
crossSigningCachedFuture ??=
client.encryption.crossSigning.isCached().then((c) {
if (mounted) setState(() => crossSigningCached = c);
return c;
});
megolmBackupCachedFuture ??=
client.encryption.keyManager.isCached().then((c) {
if (mounted) setState(() => megolmBackupCached = c);
return c;
});
return Scaffold(
body: NestedScrollView(
@ -286,6 +343,110 @@ class _SettingsState extends State<Settings> {
onTap: () => logoutAction(context),
),
Divider(thickness: 1),
ListTile(
title: Text(
L10n.of(context).encryption,
style: TextStyle(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
),
),
),
ListTile(
trailing: Icon(Icons.compare_arrows),
title: Text(client.encryption.crossSigning.enabled
? L10n.of(context).crossSigningEnabled
: L10n.of(context).crossSigningDisabled),
subtitle: client.encryption.crossSigning.enabled
? Text(client.isUnknownSession
? L10n.of(context).unknownSessionVerify
: L10n.of(context).sessionVerified +
', ' +
(crossSigningCached == null
? ''
: (crossSigningCached
? L10n.of(context).keysCached
: L10n.of(context).keysMissing)))
: null,
onTap: () async {
if (!client.encryption.crossSigning.enabled) {
await SimpleDialogs(context).inform(
contentText: L10n.of(context).noCrossSignBootstrap,
);
return;
}
if (client.isUnknownSession) {
final str = await SimpleDialogs(context).enterText(
titleText: L10n.of(context).askSSSSVerify,
hintText: L10n.of(context).passphraseOrKey,
password: true,
);
if (str != null) {
SimpleDialogs(context).showLoadingDialog(context);
// make sure the loading spinner shows before we test the keys
await Future.delayed(Duration(milliseconds: 100));
var valid = false;
try {
await client.encryption.crossSigning
.selfSign(recoveryKey: str);
valid = true;
} catch (_) {
try {
await client.encryption.crossSigning
.selfSign(passphrase: str);
valid = true;
} catch (_) {
valid = false;
}
}
await Navigator.of(context)?.pop();
if (valid) {
await SimpleDialogs(context).inform(
contentText: L10n.of(context).verifiedSession,
);
setState(() {
crossSigningCachedFuture = null;
crossSigningCached = null;
megolmBackupCachedFuture = null;
megolmBackupCached = null;
});
} else {
await SimpleDialogs(context).inform(
contentText: L10n.of(context).incorrectPassphraseOrKey,
);
}
}
}
if (!(await client.encryption.crossSigning.isCached())) {
await requestSSSSCache(context);
}
},
),
ListTile(
trailing: Icon(Icons.wb_cloudy),
title: Text(client.encryption.keyManager.enabled
? L10n.of(context).onlineKeyBackupEnabled
: L10n.of(context).onlineKeyBackupDisabled),
subtitle: client.encryption.keyManager.enabled
? Text(megolmBackupCached == null
? ''
: (megolmBackupCached
? L10n.of(context).keysCached
: L10n.of(context).keysMissing))
: null,
onTap: () async {
if (!client.encryption.keyManager.enabled) {
await SimpleDialogs(context).inform(
contentText: L10n.of(context).noMegolmBootstrap,
);
return;
}
if (!(await client.encryption.keyManager.isCached())) {
await requestSSSSCache(context);
}
},
),
Divider(thickness: 1),
ListTile(
title: Text(
L10n.of(context).about,