diff --git a/lib/components/paste_invite_dialog.dart b/lib/components/paste_invite_dialog.dart index b5153c7..1009d4b 100644 --- a/lib/components/paste_invite_dialog.dart +++ b/lib/components/paste_invite_dialog.dart @@ -16,6 +16,7 @@ import '../tools/tools.dart'; import '../veilid_support/veilid_support.dart'; import 'contact_invitation_display.dart'; import 'enter_pin.dart'; +import 'profile_widget.dart'; class PasteInviteDialog extends ConsumerStatefulWidget { const PasteInviteDialog({super.key}); @@ -26,12 +27,12 @@ class PasteInviteDialog extends ConsumerStatefulWidget { class PasteInviteDialogState extends ConsumerState { final _pasteTextController = TextEditingController(); - final _messageTextController = TextEditingController(); EncryptionKeyType _encryptionKeyType = EncryptionKeyType.none; String _encryptionKey = ''; Timestamp? _expiration; - proto.SignedContactInvitation? _validInvitation; + ValidContactInvitation? _validInvitation; + bool _validatingPaste = false; @override void initState() { @@ -93,11 +94,28 @@ class PasteInviteDialogState extends ConsumerState { // }); // } + Future _onAccept() async { + Navigator.of(context).pop(); + if (_validInvitation != null) { + return acceptContactInvitation(_validInvitation); + } + } + + Future _onReject() async { + Navigator.of(context).pop(); + if (_validInvitation != null) { + return rejectContactInvitation(_validInvitation); + } + } + Future _onPasteChanged(String text) async { try { final lines = text.split('\n'); if (lines.isEmpty) { - _validInvitation = null; + setState(() { + _validatingPaste = false; + _validInvitation = null; + }); return; } @@ -111,74 +129,44 @@ class PasteInviteDialogState extends ConsumerState { lastline = lines.length; } if (lastline <= firstline) { - _validInvitation = null; + setState(() { + _validatingPaste = false; + _validInvitation = null; + }); return; } final inviteDataBase64 = lines.sublist(firstline, lastline).join(); final inviteData = base64UrlNoPadDecode(inviteDataBase64); - final signedContactInvitation = - proto.SignedContactInvitation.fromBuffer(inviteData); - final contactInvitationBytes = - Uint8List.fromList(signedContactInvitation.contactInvitation); - final contactInvitation = - proto.ContactInvitation.fromBuffer(contactInvitationBytes); - - final contactRequestInboxKey = proto.TypedKeyProto.fromProto( - contactInvitation.contactRequestInboxKey); - - // xxx should ensure contact request is not from ourselves - // xxx or implement as 'note to self' but this could be done more carefully - // xxx this operation gets the wrong parent. can we allow opening dht records - // xxx that we already have open for readonly? - - // xxx test on multiple devices - - // Open context request inbox subkey zero to get the contact request object - final pool = await DHTRecordPool.instance(); - await (await pool.openRead(contactRequestInboxKey)) - .deleteScope((contactRequestInbox) async { - // - final contactRequest = await contactRequestInbox - .getProtobuf(proto.ContactRequest.fromBuffer); - // Decrypt contact request private - final encryptionKeyType = - EncryptionKeyType.fromProto(contactRequest!.encryptionKeyType); - late final SecretKey writerSecret; + setState(() { + _validatingPaste = true; + _validInvitation = null; + }); + final validatedContactInvitation = await validateContactInvitation( + inviteData, (encryptionKeyType, encryptedSecret) async { switch (encryptionKeyType) { case EncryptionKeyType.none: - writerSecret = SecretKey.fromBytes( - Uint8List.fromList(contactInvitation.writerSecret)); + return SecretKey.fromBytes(encryptedSecret); case EncryptionKeyType.pin: - // + //xxx + return SecretKey.fromBytes(encryptedSecret); case EncryptionKeyType.password: - // + //xxx + return SecretKey.fromBytes(encryptedSecret); } - final cs = - await pool.veilid.getCryptoSystem(contactRequestInboxKey.kind); - final contactRequestPrivateBytes = await cs.decryptNoAuthWithNonce( - Uint8List.fromList(contactRequest.private), writerSecret); - final contactRequestPrivate = - proto.ContactRequestPrivate.fromBuffer(contactRequestPrivateBytes); - final contactIdentityMasterRecordKey = proto.TypedKeyProto.fromProto( - contactRequestPrivate.identityMasterRecordKey); + }); + // Verify expiration + // xxx - // Fetch the account master - final contactIdentityMaster = await openIdentityMaster( - identityMasterRecordKey: contactIdentityMasterRecordKey); - - // Verify - final signature = proto.SignatureProto.fromProto( - signedContactInvitation.identitySignature); - await cs.verify(contactIdentityMaster.identityPublicKey, - contactInvitationBytes, signature); - - // Verify expiration - //xxx - _validInvitation = signedContactInvitation; + setState(() { + _validatingPaste = false; + _validInvitation = validatedContactInvitation; }); } on Exception catch (_) { - _validInvitation = null; + setState(() { + _validatingPaste = false; + _validInvitation = null; + }); } } @@ -188,7 +176,7 @@ class PasteInviteDialogState extends ConsumerState { final theme = Theme.of(context); //final scale = theme.extension()!; final textTheme = theme.textTheme; - final height = MediaQuery.of(context).size.height; + //final height = MediaQuery.of(context).size.height; return SizedBox( height: 400, @@ -204,6 +192,7 @@ class PasteInviteDialogState extends ConsumerState { Container( constraints: const BoxConstraints(maxHeight: 200), child: TextField( + enabled: !_validatingPaste, onChanged: _onPasteChanged, style: textTheme.labelSmall! .copyWith(fontFamily: 'Victor Mono', fontSize: 11), @@ -218,31 +207,28 @@ class PasteInviteDialogState extends ConsumerState { //labelText: translate('paste_invite_dialog.paste') ), ).paddingAll(8)), - if (_validInvitation != null) - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - ElevatedButton.icon( - icon: const Icon(Icons.check_circle), - label: Text(translate('button.accept')), - onPressed: () { - // - }, - ), - ElevatedButton.icon( - icon: const Icon(Icons.cancel), - label: Text(translate('button.reject')), - onPressed: () { - // - }, - ) - ], - ), - TextField( - enabled: false, - controller: _messageTextController, - style: Theme.of(context).textTheme.bodySmall, - ).paddingAll(8), + if (_validInvitation != null && !_validatingPaste) + Column(children: [ + ProfileWidget( + name: _validInvitation!.contactRequestPrivate.profile.name, + title: + _validInvitation!.contactRequestPrivate.profile.title), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ElevatedButton.icon( + icon: const Icon(Icons.check_circle), + label: Text(translate('button.accept')), + onPressed: _onAccept, + ), + ElevatedButton.icon( + icon: const Icon(Icons.cancel), + label: Text(translate('button.reject')), + onPressed: _onReject, + ) + ], + ), + ]) ], ), ), @@ -252,7 +238,5 @@ class PasteInviteDialogState extends ConsumerState { @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); - properties.add(DiagnosticsProperty( - 'messageTextController', _messageTextController)); } } diff --git a/lib/components/profile.dart b/lib/components/profile_widget.dart similarity index 86% rename from lib/components/profile.dart rename to lib/components/profile_widget.dart index 2a8a090..6ee4f69 100644 --- a/lib/components/profile.dart +++ b/lib/components/profile_widget.dart @@ -30,11 +30,9 @@ class ProfileWidget extends ConsumerWidget { borderRadius: BorderRadius.circular(16), side: BorderSide(color: scale.primaryScale.border))), child: Column(mainAxisSize: MainAxisSize.min, children: [ - Text(name, style: Theme.of(context).textTheme.headlineSmall) - .paddingAll(8), + Text(name, style: textTheme.headlineSmall).paddingAll(8), if (title != null && title!.isNotEmpty) - Text(title!, style: Theme.of(context).textTheme.bodyMedium) - .paddingLTRB(8, 0, 8, 8), + Text(title!, style: textTheme.bodyMedium).paddingLTRB(8, 0, 8, 8), ])).paddingAll(8); } diff --git a/lib/pages/main_pager/account_page.dart b/lib/pages/main_pager/account_page.dart index 69a9a78..1b74166 100644 --- a/lib/pages/main_pager/account_page.dart +++ b/lib/pages/main_pager/account_page.dart @@ -7,7 +7,7 @@ import 'package:flutter_translate/flutter_translate.dart'; import '../../components/contact_invitation_list_widget.dart'; import '../../components/contact_list_widget.dart'; -import '../../components/profile.dart'; +import '../../components/profile_widget.dart'; import '../../entities/local_account.dart'; import '../../entities/proto.dart' as proto; import '../../providers/account.dart'; diff --git a/lib/providers/contact.dart b/lib/providers/contact.dart index a36151c..5159916 100644 --- a/lib/providers/contact.dart +++ b/lib/providers/contact.dart @@ -4,6 +4,7 @@ import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:fixnum/fixnum.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../entities/identity.dart'; import '../entities/local_account.dart'; import '../entities/proto.dart' as proto; import '../entities/proto.dart' @@ -155,6 +156,93 @@ Future createContactInvitation( return signedContactInvitationBytes; } +class ValidContactInvitation { + ValidContactInvitation( + {required this.signedContactInvitation, + required this.contactInvitation, + required this.contactRequestInboxKey, + required this.contactRequest, + required this.contactRequestPrivate, + required this.contactIdentityMaster}); + + SignedContactInvitation signedContactInvitation; + ContactInvitation contactInvitation; + TypedKey contactRequestInboxKey; + ContactRequest contactRequest; + ContactRequestPrivate contactRequestPrivate; + IdentityMaster contactIdentityMaster; +} + +typedef GetEncryptionKeyCallback = Future Function( + EncryptionKeyType encryptionKeyType, Uint8List encryptedSecret); + +Future validateContactInvitation(Uint8List inviteData, + GetEncryptionKeyCallback getEncryptionKeyCallback) async { + final signedContactInvitation = + proto.SignedContactInvitation.fromBuffer(inviteData); + + final contactInvitationBytes = + Uint8List.fromList(signedContactInvitation.contactInvitation); + final contactInvitation = + proto.ContactInvitation.fromBuffer(contactInvitationBytes); + + final contactRequestInboxKey = + proto.TypedKeyProto.fromProto(contactInvitation.contactRequestInboxKey); + + late final ValidContactInvitation out; + + final pool = await DHTRecordPool.instance(); + await (await pool.openRead(contactRequestInboxKey)) + .deleteScope((contactRequestInbox) async { + // + final contactRequest = + await contactRequestInbox.getProtobuf(proto.ContactRequest.fromBuffer); + // Decrypt contact request private + final encryptionKeyType = + EncryptionKeyType.fromProto(contactRequest!.encryptionKeyType); + final writerSecret = await getEncryptionKeyCallback( + encryptionKeyType, Uint8List.fromList(contactInvitation.writerSecret)); + + final cs = await pool.veilid.getCryptoSystem(contactRequestInboxKey.kind); + final contactRequestPrivateBytes = await cs.decryptNoAuthWithNonce( + Uint8List.fromList(contactRequest.private), writerSecret); + final contactRequestPrivate = + proto.ContactRequestPrivate.fromBuffer(contactRequestPrivateBytes); + final contactIdentityMasterRecordKey = proto.TypedKeyProto.fromProto( + contactRequestPrivate.identityMasterRecordKey); + + // Fetch the account master + final contactIdentityMaster = await openIdentityMaster( + identityMasterRecordKey: contactIdentityMasterRecordKey); + + // Verify + final signature = proto.SignatureProto.fromProto( + signedContactInvitation.identitySignature); + await cs.verify(contactIdentityMaster.identityPublicKey, + contactInvitationBytes, signature); + + out = ValidContactInvitation( + signedContactInvitation: signedContactInvitation, + contactInvitation: contactInvitation, + contactRequestInboxKey: contactRequestInboxKey, + contactRequest: contactRequest, + contactRequestPrivate: contactRequestPrivate, + contactIdentityMaster: contactIdentityMaster); + }); + + return out; +} + +Future acceptContactInvitation( + ValidContactInvitation validContactInvitation) async { + // +} + +Future rejectContactInvitation( + ValidContactInvitation validContactInvitation) async { + // +} + /// Get the active account contact invitation list @riverpod Future?> fetchContactInvitationRecords( diff --git a/lib/veilid_support/identity_master.dart b/lib/veilid_support/identity_master.dart index d8244ce..fdde657 100644 --- a/lib/veilid_support/identity_master.dart +++ b/lib/veilid_support/identity_master.dart @@ -90,9 +90,8 @@ Future openIdentityMaster( // IdentityMaster DHT record is public/unencrypted return (await pool.openRead(identityMasterRecordKey)) .deleteScope((masterRec) async { - final identityMasterJson = + final identityMaster = (await masterRec.getJson(IdentityMaster.fromJson, forceRefresh: true))!; - final identityMaster = IdentityMaster.fromJson(identityMasterJson); // Validate IdentityMaster final masterRecordKey = masterRec.key;