diff --git a/doc/invitations.md b/doc/invitations.md index ca70b87..b448d85 100644 --- a/doc/invitations.md +++ b/doc/invitations.md @@ -34,9 +34,10 @@ 3. Set ContactRequest unicastinbox DHT record writer subkey with SignedContactResponse, encrypted with writer secret ## Receiving an accept/reject -1. Decrypt with writer secret -2. Get DHT record for contact's AccountMaster -3. Validate the SignedContactResponse signature +1. Open and get SignedContactResponse from ContactRequest unicaseinbox DHT record +2. Decrypt with writer secret +3. Get DHT record for contact's AccountMaster +4. Validate the SignedContactResponse signature If accept == false: 1. Announce rejection diff --git a/lib/components/contact_invitation_item_widget.dart b/lib/components/contact_invitation_item_widget.dart index 3eacc06..314e039 100644 --- a/lib/components/contact_invitation_item_widget.dart +++ b/lib/components/contact_invitation_item_widget.dart @@ -57,6 +57,7 @@ class ContactInvitationItemWidget extends ConsumerWidget { await ref.read(fetchActiveAccountProvider.future); if (activeAccountInfo != null) { await deleteContactInvitation( + accepted: false, activeAccountInfo: activeAccountInfo, contactInvitationRecord: contactInvitationRecord); ref.invalidate(fetchContactInvitationRecordsProvider); diff --git a/lib/entities/proto/veilidchat.pb.dart b/lib/entities/proto/veilidchat.pb.dart index dd66195..cb258fc 100644 --- a/lib/entities/proto/veilidchat.pb.dart +++ b/lib/entities/proto/veilidchat.pb.dart @@ -1575,7 +1575,7 @@ class ContactResponse extends $pb.GeneratedMessage { static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'ContactResponse', createEmptyInstance: create) ..aOB(1, _omitFieldNames ? '' : 'accept') - ..aOM(2, _omitFieldNames ? '' : 'accountMasterRecordKey', subBuilder: TypedKey.create) + ..aOM(2, _omitFieldNames ? '' : 'identityMasterRecordKey', subBuilder: TypedKey.create) ..aOM(3, _omitFieldNames ? '' : 'remoteConversationKey', subBuilder: TypedKey.create) ..hasRequiredFields = false ; @@ -1611,15 +1611,15 @@ class ContactResponse extends $pb.GeneratedMessage { void clearAccept() => clearField(1); @$pb.TagNumber(2) - TypedKey get accountMasterRecordKey => $_getN(1); + TypedKey get identityMasterRecordKey => $_getN(1); @$pb.TagNumber(2) - set accountMasterRecordKey(TypedKey v) { setField(2, v); } + set identityMasterRecordKey(TypedKey v) { setField(2, v); } @$pb.TagNumber(2) - $core.bool hasAccountMasterRecordKey() => $_has(1); + $core.bool hasIdentityMasterRecordKey() => $_has(1); @$pb.TagNumber(2) - void clearAccountMasterRecordKey() => clearField(2); + void clearIdentityMasterRecordKey() => clearField(2); @$pb.TagNumber(2) - TypedKey ensureAccountMasterRecordKey() => $_ensure(1); + TypedKey ensureIdentityMasterRecordKey() => $_ensure(1); @$pb.TagNumber(3) TypedKey get remoteConversationKey => $_getN(2); diff --git a/lib/entities/proto/veilidchat.pbjson.dart b/lib/entities/proto/veilidchat.pbjson.dart index cb3e9f3..ee6d5c4 100644 --- a/lib/entities/proto/veilidchat.pbjson.dart +++ b/lib/entities/proto/veilidchat.pbjson.dart @@ -427,17 +427,17 @@ const ContactResponse$json = { '1': 'ContactResponse', '2': [ {'1': 'accept', '3': 1, '4': 1, '5': 8, '10': 'accept'}, - {'1': 'account_master_record_key', '3': 2, '4': 1, '5': 11, '6': '.TypedKey', '10': 'accountMasterRecordKey'}, + {'1': 'identity_master_record_key', '3': 2, '4': 1, '5': 11, '6': '.TypedKey', '10': 'identityMasterRecordKey'}, {'1': 'remote_conversation_key', '3': 3, '4': 1, '5': 11, '6': '.TypedKey', '10': 'remoteConversationKey'}, ], }; /// Descriptor for `ContactResponse`. Decode as a `google.protobuf.DescriptorProto`. final $typed_data.Uint8List contactResponseDescriptor = $convert.base64Decode( - 'Cg9Db250YWN0UmVzcG9uc2USFgoGYWNjZXB0GAEgASgIUgZhY2NlcHQSRAoZYWNjb3VudF9tYX' - 'N0ZXJfcmVjb3JkX2tleRgCIAEoCzIJLlR5cGVkS2V5UhZhY2NvdW50TWFzdGVyUmVjb3JkS2V5' - 'EkEKF3JlbW90ZV9jb252ZXJzYXRpb25fa2V5GAMgASgLMgkuVHlwZWRLZXlSFXJlbW90ZUNvbn' - 'ZlcnNhdGlvbktleQ=='); + 'Cg9Db250YWN0UmVzcG9uc2USFgoGYWNjZXB0GAEgASgIUgZhY2NlcHQSRgoaaWRlbnRpdHlfbW' + 'FzdGVyX3JlY29yZF9rZXkYAiABKAsyCS5UeXBlZEtleVIXaWRlbnRpdHlNYXN0ZXJSZWNvcmRL' + 'ZXkSQQoXcmVtb3RlX2NvbnZlcnNhdGlvbl9rZXkYAyABKAsyCS5UeXBlZEtleVIVcmVtb3RlQ2' + '9udmVyc2F0aW9uS2V5'); @$core.Deprecated('Use signedContactResponseDescriptor instead') const SignedContactResponse$json = { diff --git a/lib/entities/veilidchat.proto b/lib/entities/veilidchat.proto index c4a01b4..3f03158 100644 --- a/lib/entities/veilidchat.proto +++ b/lib/entities/veilidchat.proto @@ -307,7 +307,7 @@ message ContactRequestPrivate { CryptoKey writer_key = 1; // Snapshot of profile Profile profile = 2; - // Identity master dht key + // Identity master DHT record key TypedKey identity_master_record_key = 3; // Local chat DHT record key TypedKey chat_record_key = 4; @@ -319,8 +319,8 @@ message ContactRequestPrivate { message ContactResponse { // Accept or reject bool accept = 1; - // Account master record key - TypedKey account_master_record_key = 2; + // Remote identity master DHT record key + TypedKey identity_master_record_key = 2; // Remote chat DHT record key if accepted TypedKey remote_conversation_key = 3; } diff --git a/lib/pages/home.dart b/lib/pages/home.dart index 94f711a..ccd38b6 100644 --- a/lib/pages/home.dart +++ b/lib/pages/home.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -5,10 +7,13 @@ import 'package:split_view/split_view.dart'; import 'package:signal_strength_indicator/signal_strength_indicator.dart'; import '../components/chat_component.dart'; +import '../providers/account.dart'; +import '../providers/contact.dart'; import '../providers/local_accounts.dart'; import '../providers/logins.dart'; import '../providers/window_control.dart'; import '../tools/tools.dart'; +import '../veilid_support/dht_support/dht_record_pool.dart'; import 'main_pager/main_pager.dart'; class HomePage extends ConsumerStatefulWidget { @@ -19,10 +24,17 @@ class HomePage extends ConsumerStatefulWidget { HomePageState createState() => HomePageState(); } +// XXX Eliminate this when we have ValueChanged +const int ticksPerContactInvitationCheck = 5; + class HomePageState extends ConsumerState with TickerProviderStateMixin { final _unfocusNode = FocusNode(); + Timer? _homeTickTimer; + bool _inHomeTick = false; + int _contactInvitationCheckTick = 0; + @override void initState() { super.initState(); @@ -31,15 +43,69 @@ class HomePageState extends ConsumerState setState(() {}); await ref.read(windowControlProvider.notifier).changeWindowSetup( TitleBarStyle.normal, OrientationCapability.normal); + + _homeTickTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (!_inHomeTick) { + unawaited(_onHomeTick()); + } + }); }); } @override void dispose() { + final homeTickTimer = _homeTickTimer; + if (homeTickTimer != null) { + homeTickTimer.cancel(); + } _unfocusNode.dispose(); super.dispose(); } + Future _onHomeTick() async { + _inHomeTick = true; + try { + // Check extant contact invitations once every 5 seconds + _contactInvitationCheckTick += 1; + if (_contactInvitationCheckTick >= ticksPerContactInvitationCheck) { + _contactInvitationCheckTick = 0; + await _doContactInvitationCheck(); + } + } finally { + _inHomeTick = false; + } + } + + Future _doContactInvitationCheck() async { + final contactInvitationRecords = + await ref.read(fetchContactInvitationRecordsProvider.future); + final activeAccountInfo = await ref.read(fetchActiveAccountProvider.future); + if (contactInvitationRecords == null || activeAccountInfo == null) { + return; + } + + final allChecks = >[]; + for (final contactInvitationRecord in contactInvitationRecords) { + allChecks.add(() async { + final acceptReject = await checkAcceptRejectContact( + activeAccountInfo: activeAccountInfo, + contactInvitationRecord: contactInvitationRecord); + if (acceptReject != null) { + if (acceptReject) { + // Accept + ref + ..invalidate(fetchContactInvitationRecordsProvider) + ..invalidate(fetchContactListProvider); + } else { + // Reject + ref.invalidate(fetchContactInvitationRecordsProvider); + } + } + }()); + } + await Future.wait(allChecks); + } + // ignore: prefer_expression_function_bodies Widget buildPhone(BuildContext context) { // diff --git a/lib/providers/contact.dart b/lib/providers/contact.dart index f8b87a3..5b89140 100644 --- a/lib/providers/contact.dart +++ b/lib/providers/contact.dart @@ -14,8 +14,8 @@ import '../entities/proto.dart' ContactInvitationRecord, ContactRequest, ContactRequestPrivate, - SignedContactInvitation, ContactResponse, + SignedContactInvitation, SignedContactResponse; import '../log/loggy.dart'; import '../tools/tools.dart'; @@ -24,9 +24,90 @@ import 'account.dart'; part 'contact.g.dart'; -Future deleteContactInvitation( +Future checkAcceptRejectContact( {required ActiveAccountInfo activeAccountInfo, required ContactInvitationRecord contactInvitationRecord}) async { + // Open the contact request inbox + try { + final pool = await DHTRecordPool.instance(); + final accountRecordKey = + activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; + final writerKey = + proto.CryptoKeyProto.fromProto(contactInvitationRecord.writerKey); + final writerSecret = + proto.CryptoKeyProto.fromProto(contactInvitationRecord.writerSecret); + final writer = TypedKeyPair( + kind: contactInvitationRecord.contactRequestInbox.recordKey.kind, + key: writerKey, + secret: writerSecret); + final acceptReject = await (await pool.openRead( + proto.TypedKeyProto.fromProto( + contactInvitationRecord.contactRequestInbox.recordKey), + crypto: await DHTRecordCryptoPrivate.fromTypedKeyPair(writer), + parent: accountRecordKey, + defaultSubkey: 1)) + .scope((contactRequestInbox) async { + // + final signedContactResponse = await contactRequestInbox + .getProtobuf(SignedContactResponse.fromBuffer, forceRefresh: true); + if (signedContactResponse == null) { + return null; + } + + final contactResponseBytes = + Uint8List.fromList(signedContactResponse.contactResponse); + final contactResponse = ContactResponse.fromBuffer(contactResponseBytes); + final contactIdentityMasterRecordKey = proto.TypedKeyProto.fromProto( + contactResponse.identityMasterRecordKey); + final cs = await pool.veilid.getCryptoSystem( + contactInvitationRecord.contactRequestInbox.recordKey.kind); + + // Fetch the remote contact's account master + final contactIdentityMaster = await openIdentityMaster( + identityMasterRecordKey: contactIdentityMasterRecordKey); + + // Verify + final signature = proto.SignatureProto.fromProto( + signedContactResponse.identitySignature); + try { + await cs.verify(contactIdentityMaster.identityPublicKey, + contactResponseBytes, signature); + } on Exception catch (e) { + log.error('Bad identity used, failed to verify: $e'); + return false; + } + return contactResponse.accept; + }); + + if (acceptReject == null) { + return null; + } + + // Add contact if accepted + if (acceptReject) { + // + await deleteContactInvitation( + accepted: true, + activeAccountInfo: activeAccountInfo, + contactInvitationRecord: contactInvitationRecord); + return true; + } else { + await deleteContactInvitation( + accepted: false, + activeAccountInfo: activeAccountInfo, + contactInvitationRecord: contactInvitationRecord); + return false; + } + } on Exception catch (e) { + log.error('Exception in checkAcceptRejectContact: $e'); + return null; + } +} + +Future deleteContactInvitation( + {required bool accepted, + required ActiveAccountInfo activeAccountInfo, + required ContactInvitationRecord contactInvitationRecord}) async { final pool = await DHTRecordPool.instance(); final accountRecordKey = activeAccountInfo.userLogin.accountRecordInfo.accountRecord.recordKey; @@ -53,12 +134,18 @@ Future deleteContactInvitation( proto.OwnedDHTRecordPointerProto.fromProto( contactInvitationRecord.contactRequestInbox), parent: accountRecordKey)) - .delete(); - await (await pool.openOwned( - proto.OwnedDHTRecordPointerProto.fromProto( - contactInvitationRecord.localConversation), - parent: accountRecordKey)) - .delete(); + .scope((contactRequestInbox) async { + // Wipe out old invitation so it shows up as invalid + await contactRequestInbox.tryWriteBytes(Uint8List(0)); + await contactRequestInbox.delete(); + }); + if (!accepted) { + await (await pool.openOwned( + proto.OwnedDHTRecordPointerProto.fromProto( + contactInvitationRecord.localConversation), + parent: accountRecordKey)) + .delete(); + } }); } @@ -255,7 +342,7 @@ Future acceptContactInvitation(ActiveAccountInfo activeAccountInfo, // xxx final contactResponse = ContactResponse() ..accept = false - ..accountMasterRecordKey = activeAccountInfo + ..identityMasterRecordKey = activeAccountInfo .localAccount.identityMaster.masterRecordKey .toProto(); final contactResponseBytes = contactResponse.writeToBuffer(); @@ -290,7 +377,7 @@ Future rejectContactInvitation(ActiveAccountInfo activeAccountInfo, final contactResponse = ContactResponse() ..accept = false - ..accountMasterRecordKey = activeAccountInfo + ..identityMasterRecordKey = activeAccountInfo .localAccount.identityMaster.masterRecordKey .toProto(); final contactResponseBytes = contactResponse.writeToBuffer(); diff --git a/lib/veilid_support/dht_support/dht_record.dart b/lib/veilid_support/dht_support/dht_record.dart index 13a9a45..648ce49 100644 --- a/lib/veilid_support/dht_support/dht_record.dart +++ b/lib/veilid_support/dht_support/dht_record.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:typed_data'; import 'package:protobuf/protobuf.dart'; -import 'package:veilid/veilid.dart'; import '../../tools/tools.dart'; import '../veilid_support.dart'; @@ -51,7 +50,7 @@ class DHTRecord { } final pool = await DHTRecordPool.instance(); await _routingContext.closeDHTRecord(_recordDescriptor.key); - pool.recordClosed(this); + pool.recordClosed(_recordDescriptor.key); _open = false; } @@ -71,7 +70,9 @@ class DHTRecord { try { return await scopeFunction(this); } finally { - await close(); + if (_valid) { + await close(); + } } } @@ -79,10 +80,14 @@ class DHTRecord { FutureOr Function(DHTRecord) scopeFunction) async { try { final out = await scopeFunction(this); - await close(); + if (_valid && _open) { + await close(); + } return out; } on Exception catch (_) { - await delete(); + if (_valid) { + await delete(); + } rethrow; } } diff --git a/lib/veilid_support/dht_support/dht_record_pool.dart b/lib/veilid_support/dht_support/dht_record_pool.dart index b840bc4..d9af1bb 100644 --- a/lib/veilid_support/dht_support/dht_record_pool.dart +++ b/lib/veilid_support/dht_support/dht_record_pool.dart @@ -1,5 +1,6 @@ import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:mutex/mutex.dart'; import '../../log/loggy.dart'; import '../veilid_support.dart'; @@ -38,14 +39,14 @@ class DHTRecordPool with AsyncTableDBBacked { DHTRecordPool._(Veilid veilid, VeilidRoutingContext routingContext) : _state = DHTRecordPoolAllocations( childrenByParent: IMap(), parentByChild: IMap()), - _opened = {}, + _opened = {}, _routingContext = routingContext, _veilid = veilid; // Persistent DHT record list DHTRecordPoolAllocations _state; // Which DHT records are currently open - final Map _opened; + final Map _opened; // Default routing context to use for new keys final VeilidRoutingContext _routingContext; // Convenience accessor @@ -89,14 +90,20 @@ class DHTRecordPool with AsyncTableDBBacked { Veilid get veilid => _veilid; - void _recordOpened(DHTRecord record) { - assert(!_opened.containsKey(record.key), 'record already opened'); - _opened[record.key] = record; + Future _recordOpened(TypedKey key) async { + // no race because dart is single threaded until async breaks + final m = _opened[key] ?? Mutex(); + _opened[key] = m; + await m.acquire(); + _opened[key] = m; } - void recordClosed(DHTRecord record) { - assert(_opened.containsKey(record.key), 'record already closed'); - _opened.remove(record.key); + void recordClosed(TypedKey key) { + final m = _opened.remove(key); + if (m == null) { + throw StateError('record already closed'); + } + m.release(); } Future deleteDeep(TypedKey parent) async { @@ -191,7 +198,8 @@ class DHTRecordPool with AsyncTableDBBacked { if (parent != null) { await _addDependency(parent, rec.key); } - _recordOpened(rec); + + await _recordOpened(rec.key); return rec; } @@ -202,25 +210,32 @@ class DHTRecordPool with AsyncTableDBBacked { TypedKey? parent, int defaultSubkey = 0, DHTRecordCrypto? crypto}) async { - // If we are opening a key that already exists - // make sure we are using the same parent if one was specified - final existingParent = _state.parentByChild[recordKey.toJson()]; - assert(existingParent == parent, 'wrong parent for opened key'); + await _recordOpened(recordKey); - // Open from the veilid api - final dhtctx = routingContext ?? _routingContext; - final recordDescriptor = await dhtctx.openDHTRecord(recordKey, null); - final rec = DHTRecord( - routingContext: dhtctx, - recordDescriptor: recordDescriptor, - defaultSubkey: defaultSubkey, - crypto: crypto ?? const DHTRecordCryptoPublic()); + late final DHTRecord rec; + try { + // If we are opening a key that already exists + // make sure we are using the same parent if one was specified + final existingParent = _state.parentByChild[recordKey.toJson()]; + assert(existingParent == parent, 'wrong parent for opened key'); - // Register the dependency if specified - if (parent != null) { - await _addDependency(parent, rec.key); + // Open from the veilid api + final dhtctx = routingContext ?? _routingContext; + final recordDescriptor = await dhtctx.openDHTRecord(recordKey, null); + rec = DHTRecord( + routingContext: dhtctx, + recordDescriptor: recordDescriptor, + defaultSubkey: defaultSubkey, + crypto: crypto ?? const DHTRecordCryptoPublic()); + + // Register the dependency if specified + if (parent != null) { + await _addDependency(parent, rec.key); + } + } on Exception catch (_) { + recordClosed(recordKey); + rethrow; } - _recordOpened(rec); return rec; } @@ -234,28 +249,35 @@ class DHTRecordPool with AsyncTableDBBacked { int defaultSubkey = 0, DHTRecordCrypto? crypto, }) async { - // If we are opening a key that already exists - // make sure we are using the same parent if one was specified - final existingParent = _state.parentByChild[recordKey.toJson()]; - assert(existingParent == parent, 'wrong parent for opened key'); + await _recordOpened(recordKey); - // Open from the veilid api - final dhtctx = routingContext ?? _routingContext; - final recordDescriptor = await dhtctx.openDHTRecord(recordKey, writer); - final rec = DHTRecord( - routingContext: dhtctx, - recordDescriptor: recordDescriptor, - defaultSubkey: defaultSubkey, - writer: writer, - crypto: crypto ?? - await DHTRecordCryptoPrivate.fromTypedKeyPair( - TypedKeyPair.fromKeyPair(recordKey.kind, writer))); + late final DHTRecord rec; + try { + // If we are opening a key that already exists + // make sure we are using the same parent if one was specified + final existingParent = _state.parentByChild[recordKey.toJson()]; + assert(existingParent == parent, 'wrong parent for opened key'); - // Register the dependency if specified - if (parent != null) { - await _addDependency(parent, rec.key); + // Open from the veilid api + final dhtctx = routingContext ?? _routingContext; + final recordDescriptor = await dhtctx.openDHTRecord(recordKey, writer); + rec = DHTRecord( + routingContext: dhtctx, + recordDescriptor: recordDescriptor, + defaultSubkey: defaultSubkey, + writer: writer, + crypto: crypto ?? + await DHTRecordCryptoPrivate.fromTypedKeyPair( + TypedKeyPair.fromKeyPair(recordKey.kind, writer))); + + // Register the dependency if specified + if (parent != null) { + await _addDependency(parent, rec.key); + } + } on Exception catch (_) { + recordClosed(recordKey); + rethrow; } - _recordOpened(rec); return rec; } diff --git a/pubspec.lock b/pubspec.lock index 29383e4..3d5747c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -781,6 +781,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.7.8" + mutex: + dependency: "direct main" + description: + name: mutex + sha256: "03116a4e46282a671b46c12de649d72c0ed18188ffe12a8d0fc63e83f4ad88f4" + url: "https://pub.dev" + source: hosted + version: "3.0.1" octo_image: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a8a2fb2..0f31c27 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,6 +44,7 @@ dependencies: json_annotation: ^4.8.1 loggy: ^2.0.3 motion_toast: ^2.7.8 + mutex: ^3.0.1 path: ^1.8.2 path_provider: ^2.0.11 pinput: ^2.3.0