diff --git a/assets/i18n/en.json b/assets/i18n/en.json index f366d25..03f1d4a 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -86,7 +86,8 @@ "paste_invite_dialog": { "paste_invite_here": "Paste your contact invite here:", "paste": "Paste", - "message_from_contact": "Message from contact" + "message_from_contact": "Message from contact", + "validating": "Validating..." }, "enter_pin_dialog": { "enter_pin": "Enter PIN", diff --git a/doc/invitations.md b/doc/invitations.md index cb5699a..ca70b87 100644 --- a/doc/invitations.md +++ b/doc/invitations.md @@ -23,13 +23,13 @@ ## Accepting an invitation 1. Create a Local Chat DHT record (no content yet, will be encrypted with DH of contact identity key) -2. Create ContactAccept with chat dht record and account master +2. Create ContactResponse with chat dht record and account master 3. Create SignedContactResponse with accept=true signed with identity 4. Set ContactRequest unicastinbox DHT record writer subkey with SignedContactResponse, encrypted with writer secret 5. Add a local contact with the remote chat dht record, updating from the remote profile in it ## Rejecting an invitation -1. Create ContactReject with account master +1. Create ContactResponse with account master 2. Create SignedContactResponse with accept=false signed with identity 3. Set ContactRequest unicastinbox DHT record writer subkey with SignedContactResponse, encrypted with writer secret diff --git a/lib/components/paste_invite_dialog.dart b/lib/components/paste_invite_dialog.dart index 1009d4b..973b2cd 100644 --- a/lib/components/paste_invite_dialog.dart +++ b/lib/components/paste_invite_dialog.dart @@ -33,6 +33,7 @@ class PasteInviteDialogState extends ConsumerState { Timestamp? _expiration; ValidContactInvitation? _validInvitation; bool _validatingPaste = false; + bool _isAccepting = false; @override void initState() { @@ -95,17 +96,51 @@ class PasteInviteDialogState extends ConsumerState { // } Future _onAccept() async { - Navigator.of(context).pop(); - if (_validInvitation != null) { - return acceptContactInvitation(_validInvitation); + final navigator = Navigator.of(context); + + setState(() { + _isAccepting = true; + }); + final activeAccountInfo = await ref.read(fetchActiveAccountProvider.future); + if (activeAccountInfo == null) { + setState(() { + _isAccepting = false; + }); + navigator.pop(); + return; } + final validInvitation = _validInvitation; + if (validInvitation != null) { + await acceptContactInvitation(activeAccountInfo, validInvitation); + } + setState(() { + _isAccepting = false; + }); + navigator.pop(); } Future _onReject() async { - Navigator.of(context).pop(); - if (_validInvitation != null) { - return rejectContactInvitation(_validInvitation); + final navigator = Navigator.of(context); + + setState(() { + _isAccepting = true; + }); + final activeAccountInfo = await ref.read(fetchActiveAccountProvider.future); + if (activeAccountInfo == null) { + setState(() { + _isAccepting = false; + }); + navigator.pop(); + return; } + final validInvitation = _validInvitation; + if (validInvitation != null) { + await rejectContactInvitation(activeAccountInfo, validInvitation); + } + setState(() { + _isAccepting = false; + }); + navigator.pop(); } Future _onPasteChanged(String text) async { @@ -178,6 +213,9 @@ class PasteInviteDialogState extends ConsumerState { final textTheme = theme.textTheme; //final height = MediaQuery.of(context).size.height; + if (_isAccepting) { + return SizedBox(height: 400, child: waitingPage(context)); + } return SizedBox( height: 400, child: SingleChildScrollView( @@ -207,6 +245,11 @@ class PasteInviteDialogState extends ConsumerState { //labelText: translate('paste_invite_dialog.paste') ), ).paddingAll(8)), + if (_validatingPaste) + Column(children: [ + Text(translate('paste_invite_dialog.validating')), + buildProgressIndicator(context), + ]), if (_validInvitation != null && !_validatingPaste) Column(children: [ ProfileWidget( @@ -232,7 +275,7 @@ class PasteInviteDialogState extends ConsumerState { ], ), ), - ); + ).withModalHUD(context, _isAccepting); } @override diff --git a/lib/entities/veilidchat.proto b/lib/entities/veilidchat.proto index cbb373e..c4a01b4 100644 --- a/lib/entities/veilidchat.proto +++ b/lib/entities/veilidchat.proto @@ -321,7 +321,7 @@ message ContactResponse { bool accept = 1; // Account master record key TypedKey account_master_record_key = 2; - // Local chat DHT record key if accepted + // Remote chat DHT record key if accepted TypedKey remote_conversation_key = 3; } diff --git a/lib/providers/contact.dart b/lib/providers/contact.dart index 5159916..f8b87a3 100644 --- a/lib/providers/contact.dart +++ b/lib/providers/contact.dart @@ -14,7 +14,10 @@ import '../entities/proto.dart' ContactInvitationRecord, ContactRequest, ContactRequestPrivate, - SignedContactInvitation; + SignedContactInvitation, + ContactResponse, + SignedContactResponse; +import '../log/loggy.dart'; import '../tools/tools.dart'; import '../veilid_support/veilid_support.dart'; import 'account.dart'; @@ -163,7 +166,8 @@ class ValidContactInvitation { required this.contactRequestInboxKey, required this.contactRequest, required this.contactRequestPrivate, - required this.contactIdentityMaster}); + required this.contactIdentityMaster, + required this.writer}); SignedContactInvitation signedContactInvitation; ContactInvitation contactInvitation; @@ -171,6 +175,7 @@ class ValidContactInvitation { ContactRequest contactRequest; ContactRequestPrivate contactRequestPrivate; IdentityMaster contactIdentityMaster; + KeyPair writer; } typedef GetEncryptionKeyCallback = Future Function( @@ -221,26 +226,92 @@ Future validateContactInvitation(Uint8List inviteData, await cs.verify(contactIdentityMaster.identityPublicKey, contactInvitationBytes, signature); + final writer = KeyPair( + key: proto.CryptoKeyProto.fromProto(contactRequestPrivate.writerKey), + secret: writerSecret); + out = ValidContactInvitation( signedContactInvitation: signedContactInvitation, contactInvitation: contactInvitation, contactRequestInboxKey: contactRequestInboxKey, contactRequest: contactRequest, contactRequestPrivate: contactRequestPrivate, - contactIdentityMaster: contactIdentityMaster); + contactIdentityMaster: contactIdentityMaster, + writer: writer); }); return out; } -Future acceptContactInvitation( +Future acceptContactInvitation(ActiveAccountInfo activeAccountInfo, ValidContactInvitation validContactInvitation) async { - // + final pool = await DHTRecordPool.instance(); + await (await pool.openWrite(validContactInvitation.contactRequestInboxKey, + validContactInvitation.writer)) + .deleteScope((contactRequestInbox) async { + final cs = await pool.veilid + .getCryptoSystem(validContactInvitation.contactRequestInboxKey.kind); + + // xxx + final contactResponse = ContactResponse() + ..accept = false + ..accountMasterRecordKey = activeAccountInfo + .localAccount.identityMaster.masterRecordKey + .toProto(); + final contactResponseBytes = contactResponse.writeToBuffer(); + + final identitySignature = await cs.sign( + activeAccountInfo.localAccount.identityMaster.identityPublicKey, + activeAccountInfo.userLogin.identitySecret.value, + contactResponseBytes); + + final signedContactResponse = SignedContactResponse() + ..contactResponse = contactResponseBytes + ..identitySignature = identitySignature.toProto(); + + // Write the rejection to the invox + if (await contactRequestInbox.tryWriteProtobuf( + SignedContactResponse.fromBuffer, signedContactResponse, + subkey: 1) != + null) { + log.error('failed to accept contact invitation'); + } + }); } -Future rejectContactInvitation( +Future rejectContactInvitation(ActiveAccountInfo activeAccountInfo, ValidContactInvitation validContactInvitation) async { - // + final pool = await DHTRecordPool.instance(); + await (await pool.openWrite(validContactInvitation.contactRequestInboxKey, + validContactInvitation.writer)) + .deleteScope((contactRequestInbox) async { + final cs = await pool.veilid + .getCryptoSystem(validContactInvitation.contactRequestInboxKey.kind); + + final contactResponse = ContactResponse() + ..accept = false + ..accountMasterRecordKey = activeAccountInfo + .localAccount.identityMaster.masterRecordKey + .toProto(); + final contactResponseBytes = contactResponse.writeToBuffer(); + + final identitySignature = await cs.sign( + activeAccountInfo.localAccount.identityMaster.identityPublicKey, + activeAccountInfo.userLogin.identitySecret.value, + contactResponseBytes); + + final signedContactResponse = SignedContactResponse() + ..contactResponse = contactResponseBytes + ..identitySignature = identitySignature.toProto(); + + // Write the rejection to the invox + if (await contactRequestInbox.tryWriteProtobuf( + SignedContactResponse.fromBuffer, signedContactResponse, + subkey: 1) != + null) { + log.error('failed to reject contact invitation'); + } + }); } /// Get the active account contact invitation list