From c63eee26fd2ef626d9c0c18474607515036b2c5c Mon Sep 17 00:00:00 2001 From: Christien Rioux Date: Sun, 24 Sep 2023 22:35:54 -0400 Subject: [PATCH] encryption and dht work --- assets/i18n/en.json | 18 +- lib/components/paste_invite_dialog.dart | 159 ++++++++++-------- lib/components/scan_invite_dialog.dart | 46 +++-- lib/pages/new_account.dart | 7 +- lib/providers/contact_invite.dart | 77 +++++++-- lib/providers/logins.dart | 45 ++--- lib/tools/secret_crypto.dart | 31 +++- .../dht_support/dht_record.dart | 9 + .../dht_support/dht_record_pool.dart | 13 +- 9 files changed, 246 insertions(+), 159 deletions(-) diff --git a/assets/i18n/en.json b/assets/i18n/en.json index 85e19bb..93f27ae 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -83,15 +83,21 @@ "copy_invitation": "Copy Invitation", "invitation_copied": "Invitation Copied" }, + "contact_invite": { + "message_from_contact": "Message from contact", + "validating": "Validating...", + "failed_to_accept": "Failed to accept contact invite", + "failed_to_reject": "Failed to reject contact invite", + "invalid_invitation": "Invalid invitation", + "protected_with_pin": "Contact invite is protected with a PIN", + "protected_with_password": "Contact invite is protected with a password", + "invalid_pin": "Invalid PIN", + "invalid_password": "Invalid password" + }, "paste_invite_dialog": { "title": "Paste Contact Invite", "paste_invite_here": "Paste your contact invite here:", - "paste": "Paste", - "message_from_contact": "Message from contact", - "validating": "Validating...", - "invalid_invitation": "Invalid invitation", - "failed_to_accept": "Failed to accept contact invite", - "failed_to_reject": "Failed to reject contact invite" + "paste": "Paste" }, "enter_pin_dialog": { "enter_pin": "Enter PIN", diff --git a/lib/components/paste_invite_dialog.dart b/lib/components/paste_invite_dialog.dart index 189ad11..40ddb4b 100644 --- a/lib/components/paste_invite_dialog.dart +++ b/lib/components/paste_invite_dialog.dart @@ -33,9 +33,6 @@ class PasteInviteDialog extends ConsumerStatefulWidget { class PasteInviteDialogState extends ConsumerState { final _pasteTextController = TextEditingController(); - EncryptionKeyType _encryptionKeyType = EncryptionKeyType.none; - String _encryptionKey = ''; - Timestamp? _expiration; ValidContactInvitation? _validInvitation; bool _validatingPaste = false; bool _isAccepting = false; @@ -45,61 +42,6 @@ class PasteInviteDialogState extends ConsumerState { super.initState(); } - // Future _onNoneEncryptionSelected(bool selected) async { - // setState(() { - // if (selected) { - // _encryptionKeyType = EncryptionKeyType.none; - // } - // }); - // } - - // Future _onPinEncryptionSelected(bool selected) async { - // final description = translate('receive_invite_dialog.pin_description'); - // final pin = await showDialog( - // context: context, - // builder: (context) => EnterPinDialog(description: description)); - // if (pin == null) { - // return; - // } - // // ignore: use_build_context_synchronously - // if (!context.mounted) { - // return; - // } - // final matchpin = await showDialog( - // context: context, - // builder: (context) => EnterPinDialog( - // matchPin: pin, - // description: description, - // )); - // if (matchpin == null) { - // return; - // } else if (pin == matchpin) { - // setState(() { - // _encryptionKeyType = EncryptionKeyType.pin; - // _encryptionKey = pin; - // }); - // } else { - // // ignore: use_build_context_synchronously - // if (!context.mounted) { - // return; - // } - // showErrorToast( - // context, translate('receive_invite_dialog.pin_does_not_match')); - // setState(() { - // _encryptionKeyType = EncryptionKeyType.none; - // _encryptionKey = ''; - // }); - // } - // } - - // Future _onPasswordEncryptionSelected(bool selected) async { - // setState(() { - // if (selected) { - // _encryptionKeyType = EncryptionKeyType.password; - // } - // }); - // } - Future _onAccept() async { final navigator = Navigator.of(context); @@ -133,7 +75,7 @@ class PasteInviteDialogState extends ConsumerState { ..invalidate(fetchContactListProvider); } else { if (context.mounted) { - showErrorToast(context, 'paste_invite_dialog.failed_to_accept'); + showErrorToast(context, 'contact_invite.failed_to_accept'); } } } @@ -163,7 +105,7 @@ class PasteInviteDialogState extends ConsumerState { // do nothing right now } else { if (context.mounted) { - showErrorToast(context, 'paste_invite_dialog.failed_to_reject'); + showErrorToast(context, 'contact_invite.failed_to_reject'); } } } @@ -203,23 +145,74 @@ class PasteInviteDialogState extends ConsumerState { final inviteDataBase64 = lines.sublist(firstline, lastline).join(); final inviteData = base64UrlNoPadDecode(inviteDataBase64); + final activeAccountInfo = + await ref.read(fetchActiveAccountProvider.future); + if (activeAccountInfo == null) { + setState(() { + _validatingPaste = false; + _validInvitation = null; + }); + return; + } + setState(() { _validatingPaste = true; _validInvitation = null; }); final validatedContactInvitation = await validateContactInvitation( - inviteData, (encryptionKeyType, encryptedSecret) async { - switch (encryptionKeyType) { - case EncryptionKeyType.none: - return SecretKey.fromBytes(encryptedSecret); - case EncryptionKeyType.pin: - //xxx - return SecretKey.fromBytes(encryptedSecret); - case EncryptionKeyType.password: - //xxx - return SecretKey.fromBytes(encryptedSecret); - } - }); + activeAccountInfo: activeAccountInfo, + inviteData: inviteData, + getEncryptionKeyCallback: + (cs, encryptionKeyType, encryptedSecret) async { + String encryptionKey; + switch (encryptionKeyType) { + case EncryptionKeyType.none: + encryptionKey = ''; + case EncryptionKeyType.pin: + final description = + translate('contact_invite.protected_with_pin'); + if (!context.mounted) { + return null; + } + final pin = await showDialog( + context: context, + builder: (context) => + EnterPinDialog(description: description)); + if (pin == null) { + return null; + } + encryptionKey = pin; + case EncryptionKeyType.password: + final description = + translate('contact_invite.protected_with_pin'); + if (!context.mounted) { + return null; + } + final password = await showDialog( + context: context, + builder: (context) => + EnterPinDialog(description: description)); + if (password == null) { + return null; + } + encryptionKey = password; + } + return decryptSecretFromBytes( + secretBytes: encryptedSecret, + cryptoKind: cs.kind(), + encryptionKeyType: encryptionKeyType, + encryptionKey: encryptionKey); + }); + + // Check if validation was cancelled + if (validatedContactInvitation == null) { + setState(() { + _validatingPaste = false; + _validInvitation = null; + }); + return; + } + // Verify expiration // xxx @@ -227,11 +220,29 @@ class PasteInviteDialogState extends ConsumerState { _validatingPaste = false; _validInvitation = validatedContactInvitation; }); + } on ContactInviteInvalidKeyException catch (e) { + String errorText; + switch (e.type) { + case EncryptionKeyType.none: + errorText = translate('contact_invite.invalid_invitation'); + case EncryptionKeyType.password: + errorText = translate('contact_invite.invalid_pin'); + case EncryptionKeyType.pin: + errorText = translate('contact_invite.invalid_password'); + } + if (context.mounted) { + showErrorToast(context, errorText); + } + setState(() { + _validatingPaste = false; + _validInvitation = null; + }); } on Exception catch (_) { setState(() { _validatingPaste = false; _validInvitation = null; }); + rethrow; } } @@ -277,7 +288,7 @@ class PasteInviteDialogState extends ConsumerState { )).paddingLTRB(0, 0, 0, 8), if (_validatingPaste) Column(children: [ - Text(translate('paste_invite_dialog.validating')) + Text(translate('contact_invite.validating')) .paddingLTRB(0, 0, 0, 8), buildProgressIndicator(context), ]).paddingAll(16).toCenter(), @@ -285,7 +296,7 @@ class PasteInviteDialogState extends ConsumerState { !_validatingPaste && _pasteTextController.text.isNotEmpty) Column(children: [ - Text(translate('paste_invite_dialog.invalid_invitation')), + Text(translate('contact_invite.invalid_invitation')), const Icon(Icons.error) ]).paddingAll(16).toCenter(), if (_validInvitation != null && !_validatingPaste) diff --git a/lib/components/scan_invite_dialog.dart b/lib/components/scan_invite_dialog.dart index 320ae24..d259de4 100644 --- a/lib/components/scan_invite_dialog.dart +++ b/lib/components/scan_invite_dialog.dart @@ -196,30 +196,42 @@ class ScanInviteDialogState extends ConsumerState { final inviteDataBase64 = lines.sublist(firstline, lastline).join(); final inviteData = base64UrlNoPadDecode(inviteDataBase64); + final activeAccountInfo = + await ref.read(fetchActiveAccountProvider.future); + if (activeAccountInfo == null) { + setState(() { + _validatingPaste = false; + _validInvitation = null; + }); + return; + } + setState(() { _validatingPaste = true; _validInvitation = null; }); - final validatedContactInvitation = await validateContactInvitation( - inviteData, (encryptionKeyType, encryptedSecret) async { - switch (encryptionKeyType) { - case EncryptionKeyType.none: - return SecretKey.fromBytes(encryptedSecret); - case EncryptionKeyType.pin: - //xxx - return SecretKey.fromBytes(encryptedSecret); - case EncryptionKeyType.password: - //xxx - return SecretKey.fromBytes(encryptedSecret); - } - }); + // final validatedContactInvitation = await validateContactInvitation( + // activeAccountInfo: activeAccountInfo, + // inviteData: inviteData, + // getEncryptionKeyCallback: (encryptionKeyType, encryptedSecret) async { + // switch (encryptionKeyType) { + // case EncryptionKeyType.none: + // return SecretKey.fromBytes(encryptedSecret); + // case EncryptionKeyType.pin: + // //xxx + // return SecretKey.fromBytes(encryptedSecret); + // case EncryptionKeyType.password: + // //xxx + // return SecretKey.fromBytes(encryptedSecret); + // } + // }); // Verify expiration // xxx - setState(() { - _validatingPaste = false; - _validInvitation = validatedContactInvitation; - }); + // setState(() { + // _validatingPaste = false; + // _validInvitation = validatedContactInvitation; + // }); } on Exception catch (_) { setState(() { _validatingPaste = false; diff --git a/lib/pages/new_account.dart b/lib/pages/new_account.dart index d745185..0d8338f 100644 --- a/lib/pages/new_account.dart +++ b/lib/pages/new_account.dart @@ -9,6 +9,7 @@ import 'package:go_router/go_router.dart'; import '../components/default_app_bar.dart'; import '../components/signal_strength_meter.dart'; +import '../entities/entities.dart'; import '../providers/local_accounts.dart'; import '../providers/logins.dart'; import '../providers/window_control.dart'; @@ -59,9 +60,9 @@ class NewAccountPageState extends ConsumerState { title: title); // Log in the new account by default with no pin - final ok = await logins - .loginWithNone(localAccount.identityMaster.masterRecordKey); - assert(ok == true, 'login with none should never fail'); + final ok = await logins.login(localAccount.identityMaster.masterRecordKey, + EncryptionKeyType.none, ''); + assert(ok, 'login with none should never fail'); } on Exception catch (_) { await imws.delete(); rethrow; diff --git a/lib/providers/contact_invite.dart b/lib/providers/contact_invite.dart index 54fbf17..054e1e9 100644 --- a/lib/providers/contact_invite.dart +++ b/lib/providers/contact_invite.dart @@ -24,6 +24,11 @@ import 'conversation.dart'; part 'contact_invite.g.dart'; +class ContactInviteInvalidKeyException implements Exception { + const ContactInviteInvalidKeyException(this.type) : super(); + final EncryptionKeyType type; +} + class AcceptedContact { AcceptedContact({ required this.profile, @@ -238,8 +243,8 @@ Future createContactInvitation( ..chatRecordKey = localConversation.key.toProto() ..expiration = expiration?.toInt64() ?? Int64.ZERO; final crprivbytes = crpriv.writeToBuffer(); - final encryptedContactRequestPrivate = await cs.encryptNoAuthWithNonce( - crprivbytes, contactRequestWriter.secret); + final encryptedContactRequestPrivate = + await cs.encryptAeadWithNonce(crprivbytes, contactRequestWriter.secret); // Create ContactRequest and embed contactrequestprivate final creq = ContactRequest() @@ -315,11 +320,18 @@ class ValidContactInvitation { KeyPair writer; } -typedef GetEncryptionKeyCallback = Future Function( - EncryptionKeyType encryptionKeyType, Uint8List encryptedSecret); +typedef GetEncryptionKeyCallback = Future Function( + VeilidCryptoSystem cs, + EncryptionKeyType encryptionKeyType, + Uint8List encryptedSecret); + +Future validateContactInvitation( + {required ActiveAccountInfo activeAccountInfo, + required Uint8List inviteData, + required GetEncryptionKeyCallback getEncryptionKeyCallback}) async { + final accountRecordKey = + activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; -Future validateContactInvitation(Uint8List inviteData, - GetEncryptionKeyCallback getEncryptionKeyCallback) async { final signedContactInvitation = proto.SignedContactInvitation.fromBuffer(inviteData); @@ -334,19 +346,27 @@ Future validateContactInvitation(Uint8List inviteData, late final ValidContactInvitation out; final pool = await DHTRecordPool.instance(); - await (await pool.openRead(contactRequestInboxKey)) - .deleteScope((contactRequestInbox) async { + final cs = await pool.veilid.getCryptoSystem(contactRequestInboxKey.kind); + + // See if we're chatting to ourselves, if so, don't delete it here + final ownKey = pool.getParentRecord(contactRequestInboxKey) != null; + + await (await pool.openRead(contactRequestInboxKey, parent: accountRecordKey)) + .maybeDeleteScope(!ownKey, (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 writerSecret = await getEncryptionKeyCallback(cs, encryptionKeyType, + Uint8List.fromList(contactInvitation.writerSecret)); + if (writerSecret == null) { + return null; + } - final cs = await pool.veilid.getCryptoSystem(contactRequestInboxKey.kind); - final contactRequestPrivateBytes = await cs.decryptNoAuthWithNonce( + final contactRequestPrivateBytes = await cs.decryptAeadWithNonce( Uint8List.fromList(contactRequest.private), writerSecret); final contactRequestPrivate = proto.ContactRequestPrivate.fromBuffer(contactRequestPrivateBytes); @@ -360,8 +380,12 @@ Future validateContactInvitation(Uint8List inviteData, // Verify final signature = proto.SignatureProto.fromProto( signedContactInvitation.identitySignature); - await cs.verify(contactIdentityMaster.identityPublicKey, - contactInvitationBytes, signature); + try { + await cs.verify(contactIdentityMaster.identityPublicKey, + contactInvitationBytes, signature); + } on Exception catch (_) { + throw ContactInviteInvalidKeyException(encryptionKeyType); + } final writer = KeyPair( key: proto.CryptoKeyProto.fromProto(contactRequestPrivate.writerKey), @@ -385,10 +409,18 @@ Future acceptContactInvitation( ValidContactInvitation validContactInvitation) async { final pool = await DHTRecordPool.instance(); try { + // Ensure we don't delete this if we're trying to chat to self + final ownKey = + pool.getParentRecord(validContactInvitation.contactRequestInboxKey) != + null; + final accountRecordKey = + activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; + return (await pool.openWrite(validContactInvitation.contactRequestInboxKey, - validContactInvitation.writer)) + validContactInvitation.writer, + parent: accountRecordKey)) // ignore: prefer_expression_function_bodies - .deleteScope((contactRequestInbox) async { + .maybeDeleteScope(!ownKey, (contactRequestInbox) async { // Create local conversation key for this // contact and send via contact response return createConversation( @@ -441,9 +473,18 @@ Future acceptContactInvitation( Future rejectContactInvitation(ActiveAccountInfo activeAccountInfo, ValidContactInvitation validContactInvitation) async { final pool = await DHTRecordPool.instance(); + + // Ensure we don't delete this if we're trying to chat to self + final ownKey = + pool.getParentRecord(validContactInvitation.contactRequestInboxKey) != + null; + final accountRecordKey = + activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; + return (await pool.openWrite(validContactInvitation.contactRequestInboxKey, - validContactInvitation.writer)) - .deleteScope((contactRequestInbox) async { + validContactInvitation.writer, + parent: accountRecordKey)) + .maybeDeleteScope(!ownKey, (contactRequestInbox) async { final cs = await pool.veilid .getCryptoSystem(validContactInvitation.contactRequestInboxKey.kind); diff --git a/lib/providers/logins.dart b/lib/providers/logins.dart index 5ed3a2e..aa26f3d 100644 --- a/lib/providers/logins.dart +++ b/lib/providers/logins.dart @@ -5,6 +5,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../entities/entities.dart'; import '../log/loggy.dart'; +import '../tools/tools.dart'; import '../veilid_support/veilid_support.dart'; import 'local_accounts.dart'; @@ -52,7 +53,7 @@ class Logins extends _$Logins with AsyncTableDBBacked { state = AsyncValue.data(updated); } - Future _loginCommon( + Future _decryptedLogin( IdentityMaster identityMaster, SecretKey identitySecret) async { final veilid = await eventualVeilid.future; final cs = @@ -89,7 +90,8 @@ class Logins extends _$Logins with AsyncTableDBBacked { return true; } - Future loginWithNone(TypedKey accountMasterRecordKey) async { + Future login(TypedKey accountMasterRecordKey, + EncryptionKeyType encryptionKeyType, String encryptionKey) async { final localAccounts = ref.read(localAccountsProvider).requireValue; // Get account, throws if not found @@ -99,42 +101,19 @@ class Logins extends _$Logins with AsyncTableDBBacked { // Log in with this local account // Derive key from password - if (localAccount.encryptionKeyType != EncryptionKeyType.none) { + if (localAccount.encryptionKeyType != encryptionKeyType) { throw Exception('Wrong authentication type'); } - final identitySecret = - SecretKey.fromBytes(localAccount.identitySecretBytes); + final identitySecret = await decryptSecretFromBytes( + secretBytes: localAccount.identitySecretBytes, + cryptoKind: localAccount.identityMaster.identityRecordKey.kind, + encryptionKeyType: localAccount.encryptionKeyType, + encryptionKey: encryptionKey, + ); // Validate this secret with the identity public key and log in - return _loginCommon(localAccount.identityMaster, identitySecret); - } - - Future loginWithPasswordOrPin( - TypedKey accountMasterRecordKey, String encryptionKey) async { - final veilid = await eventualVeilid.future; - final localAccounts = ref.read(localAccountsProvider).requireValue; - - // Get account, throws if not found - final localAccount = localAccounts.firstWhere( - (la) => la.identityMaster.masterRecordKey == accountMasterRecordKey); - - // Log in with this local account - - // Derive key from password - if (localAccount.encryptionKeyType != EncryptionKeyType.password || - localAccount.encryptionKeyType != EncryptionKeyType.pin) { - throw Exception('Wrong authentication type'); - } - final cs = await veilid - .getCryptoSystem(localAccount.identityMaster.identityRecordKey.kind); - - final identitySecret = SecretKey.fromBytes( - await cs.decryptNoAuthWithPassword( - localAccount.identitySecretBytes, encryptionKey)); - - // Validate this secret with the identity public key and log in - return _loginCommon(localAccount.identityMaster, identitySecret); + return _decryptedLogin(localAccount.identityMaster, identitySecret); } Future logout(TypedKey? accountMasterRecordKey) async { diff --git a/lib/tools/secret_crypto.dart b/lib/tools/secret_crypto.dart index 5d98fe4..0f4e0d6 100644 --- a/lib/tools/secret_crypto.dart +++ b/lib/tools/secret_crypto.dart @@ -9,16 +9,37 @@ Future encryptSecretToBytes( String encryptionKey = ''}) async { final veilid = await eventualVeilid.future; - late final Uint8List identitySecretBytes; + late final Uint8List secretBytes; switch (encryptionKeyType) { case EncryptionKeyType.none: - identitySecretBytes = secret.decode(); + secretBytes = secret.decode(); case EncryptionKeyType.pin: case EncryptionKeyType.password: final cs = await veilid.getCryptoSystem(cryptoKind); - identitySecretBytes = - await cs.encryptNoAuthWithPassword(secret.decode(), encryptionKey); + secretBytes = + await cs.encryptAeadWithPassword(secret.decode(), encryptionKey); } - return identitySecretBytes; + return secretBytes; +} + +Future decryptSecretFromBytes( + {required Uint8List secretBytes, + required CryptoKind cryptoKind, + EncryptionKeyType encryptionKeyType = EncryptionKeyType.none, + String encryptionKey = ''}) async { + final veilid = await eventualVeilid.future; + + late final SecretKey secret; + switch (encryptionKeyType) { + case EncryptionKeyType.none: + secret = SecretKey.fromBytes(secretBytes); + case EncryptionKeyType.pin: + case EncryptionKeyType.password: + final cs = await veilid.getCryptoSystem(cryptoKind); + + secret = SecretKey.fromBytes( + await cs.decryptAeadWithPassword(secretBytes, encryptionKey)); + } + return secret; } diff --git a/lib/veilid_support/dht_support/dht_record.dart b/lib/veilid_support/dht_support/dht_record.dart index 72c3f41..69e4c32 100644 --- a/lib/veilid_support/dht_support/dht_record.dart +++ b/lib/veilid_support/dht_support/dht_record.dart @@ -91,6 +91,15 @@ class DHTRecord { } } + Future maybeDeleteScope( + bool delete, Future Function(DHTRecord) scopeFunction) async { + if (delete) { + return deleteScope(scopeFunction); + } else { + return scope(scopeFunction); + } + } + Future get( {int subkey = -1, bool forceRefresh = false, diff --git a/lib/veilid_support/dht_support/dht_record_pool.dart b/lib/veilid_support/dht_support/dht_record_pool.dart index 507796f..c5dd22a 100644 --- a/lib/veilid_support/dht_support/dht_record_pool.dart +++ b/lib/veilid_support/dht_support/dht_record_pool.dart @@ -120,12 +120,12 @@ class DHTRecordPool with AsyncTableDBBacked { while (currentDeps.isNotEmpty) { final nextDep = currentDeps.removeLast(); + // Ensure we get the exclusive lock on this record + await _recordOpened(nextDep); + // Remove this child from its parent await _removeDependency(nextDep); - // Ensure all records are closed before delete - assert(!_opened.containsKey(nextDep), 'should not delete opened record'); - allDeps.add(nextDep); final childDeps = _state.childrenByParent[nextDep.toJson()]?.toList() ?? []; @@ -136,6 +136,7 @@ class DHTRecordPool with AsyncTableDBBacked { final allFutures = >[]; for (final dep in allDeps) { allFutures.add(_routingContext.deleteDHTRecord(dep)); + recordClosed(dep); } await Future.wait(allFutures); } @@ -324,4 +325,10 @@ class DHTRecordPool with AsyncTableDBBacked { defaultSubkey: defaultSubkey, crypto: crypto, ); + + /// Get the parent of a DHTRecord key if it exists + TypedKey? getParentRecord(TypedKey child) { + final childJson = child.toJson(); + return _state.parentByChild[childJson]; + } }