diff --git a/mobile/lib/features/channels/channel_mutes/channel_mutes_provider.dart b/mobile/lib/features/channels/channel_mutes/channel_mutes_provider.dart index 4e2d965fba..ba566566ca 100644 --- a/mobile/lib/features/channels/channel_mutes/channel_mutes_provider.dart +++ b/mobile/lib/features/channels/channel_mutes/channel_mutes_provider.dart @@ -1,6 +1,5 @@ import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:nostr/nostr.dart' as nostr; import '../../../shared/relay/relay.dart'; import '../../../shared/theme/theme_provider.dart'; @@ -42,15 +41,9 @@ class ChannelMutesNotifier extends Notifier { return const ChannelMutesState(); } - final pubkey = _safePubkeyFromNsec(nsec); - if (pubkey == null || pubkey.isEmpty) { - return const ChannelMutesState(); - } - - final ChannelMutesCrypto crypto; - try { - crypto = ChannelMutesCrypto(nsec, pubkey); - } catch (_) { + final pubkey = ref.watch(myPubkeyProvider); + final crypto = ref.watch(channelMutesCryptoProvider); + if (pubkey == null || crypto == null) { return const ChannelMutesState(); } @@ -114,12 +107,16 @@ final channelMutesProvider = ChannelMutesNotifier.new, ); -String? _safePubkeyFromNsec(String nsec) { +/// Identity-scoped crypto survives connection status changes. A community or +/// signing-key change replaces it through [relayConfigProvider]. +final channelMutesCryptoProvider = Provider((ref) { + final config = ref.watch(relayConfigProvider); + final pubkey = ref.watch(myPubkeyProvider); + final nsec = config.nsec?.trim(); + if (nsec == null || nsec.isEmpty || pubkey == null) return null; try { - final privkeyHex = nostr.Nip19.decode(payload: nsec).data; - if (privkeyHex.isEmpty) return null; - return nostr.Keys(privkeyHex).public; + return ChannelMutesCrypto(nsec, pubkey); } catch (_) { return null; } -} +}); diff --git a/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart b/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart index f10180757d..ed7eb73f89 100644 --- a/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart +++ b/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart @@ -1,6 +1,5 @@ import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:nostr/nostr.dart' as nostr; import '../../../shared/relay/relay.dart'; import '../../../shared/theme/theme_provider.dart'; @@ -42,15 +41,9 @@ class ChannelSectionsNotifier extends Notifier { return const ChannelSectionsState(); } - final pubkey = _safePubkeyFromNsec(nsec); - if (pubkey == null || pubkey.isEmpty) { - return const ChannelSectionsState(); - } - - final ChannelSectionsCrypto crypto; - try { - crypto = ChannelSectionsCrypto(nsec, pubkey); - } catch (_) { + final pubkey = ref.watch(myPubkeyProvider); + final crypto = ref.watch(channelSectionsCryptoProvider); + if (pubkey == null || crypto == null) { return const ChannelSectionsState(); } @@ -132,12 +125,16 @@ final channelSectionsProvider = ChannelSectionsNotifier.new, ); -String? _safePubkeyFromNsec(String nsec) { +/// Identity-scoped crypto survives connection status changes. A community or +/// signing-key change replaces it through [relayConfigProvider]. +final channelSectionsCryptoProvider = Provider((ref) { + final config = ref.watch(relayConfigProvider); + final pubkey = ref.watch(myPubkeyProvider); + final nsec = config.nsec?.trim(); + if (nsec == null || nsec.isEmpty || pubkey == null) return null; try { - final privkeyHex = nostr.Nip19.decode(payload: nsec).data; - if (privkeyHex.isEmpty) return null; - return nostr.Keys(privkeyHex).public; + return ChannelSectionsCrypto(nsec, pubkey); } catch (_) { return null; } -} +}); diff --git a/mobile/lib/features/channels/channel_sort/channel_sort_provider.dart b/mobile/lib/features/channels/channel_sort/channel_sort_provider.dart index afc0fd492c..c0201e4a9f 100644 --- a/mobile/lib/features/channels/channel_sort/channel_sort_provider.dart +++ b/mobile/lib/features/channels/channel_sort/channel_sort_provider.dart @@ -1,6 +1,5 @@ import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:nostr/nostr.dart' as nostr; import '../../../shared/relay/relay.dart'; import '../../../shared/theme/theme_provider.dart'; @@ -44,15 +43,9 @@ class ChannelSortNotifier extends Notifier { return const ChannelSortState(); } - final pubkey = _safePubkeyFromNsec(nsec); - if (pubkey == null || pubkey.isEmpty) { - return const ChannelSortState(); - } - - final ChannelSortCrypto crypto; - try { - crypto = ChannelSortCrypto(nsec, pubkey); - } catch (_) { + final pubkey = ref.watch(myPubkeyProvider); + final crypto = ref.watch(channelSortCryptoProvider); + if (pubkey == null || crypto == null) { return const ChannelSortState(); } @@ -124,12 +117,16 @@ final channelSortProvider = ChannelSortNotifier.new, ); -String? _safePubkeyFromNsec(String nsec) { +/// Identity-scoped crypto survives connection status changes. A community or +/// signing-key change replaces it through [relayConfigProvider]. +final channelSortCryptoProvider = Provider((ref) { + final config = ref.watch(relayConfigProvider); + final pubkey = ref.watch(myPubkeyProvider); + final nsec = config.nsec?.trim(); + if (nsec == null || nsec.isEmpty || pubkey == null) return null; try { - final privkeyHex = nostr.Nip19.decode(payload: nsec).data; - if (privkeyHex.isEmpty) return null; - return nostr.Keys(privkeyHex).public; + return ChannelSortCrypto(nsec, pubkey); } catch (_) { return null; } -} +}); diff --git a/mobile/lib/features/channels/channel_stars/channel_stars_provider.dart b/mobile/lib/features/channels/channel_stars/channel_stars_provider.dart index 2ecd1f94f5..5fabd67818 100644 --- a/mobile/lib/features/channels/channel_stars/channel_stars_provider.dart +++ b/mobile/lib/features/channels/channel_stars/channel_stars_provider.dart @@ -1,6 +1,5 @@ import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:nostr/nostr.dart' as nostr; import '../../../shared/relay/relay.dart'; import '../../../shared/theme/theme_provider.dart'; @@ -42,15 +41,9 @@ class ChannelStarsNotifier extends Notifier { return const ChannelStarsState(); } - final pubkey = _safePubkeyFromNsec(nsec); - if (pubkey == null || pubkey.isEmpty) { - return const ChannelStarsState(); - } - - final ChannelStarsCrypto crypto; - try { - crypto = ChannelStarsCrypto(nsec, pubkey); - } catch (_) { + final pubkey = ref.watch(myPubkeyProvider); + final crypto = ref.watch(channelStarsCryptoProvider); + if (pubkey == null || crypto == null) { return const ChannelStarsState(); } @@ -114,12 +107,16 @@ final channelStarsProvider = ChannelStarsNotifier.new, ); -String? _safePubkeyFromNsec(String nsec) { +/// Identity-scoped crypto survives connection status changes. A community or +/// signing-key change replaces it through [relayConfigProvider]. +final channelStarsCryptoProvider = Provider((ref) { + final config = ref.watch(relayConfigProvider); + final pubkey = ref.watch(myPubkeyProvider); + final nsec = config.nsec?.trim(); + if (nsec == null || nsec.isEmpty || pubkey == null) return null; try { - final privkeyHex = nostr.Nip19.decode(payload: nsec).data; - if (privkeyHex.isEmpty) return null; - return nostr.Keys(privkeyHex).public; + return ChannelStarsCrypto(nsec, pubkey); } catch (_) { return null; } -} +}); diff --git a/mobile/lib/features/pairing/pairing_provider.dart b/mobile/lib/features/pairing/pairing_provider.dart index c426c85494..0a7a5dcb7f 100644 --- a/mobile/lib/features/pairing/pairing_provider.dart +++ b/mobile/lib/features/pairing/pairing_provider.dart @@ -121,7 +121,9 @@ class PairingNotifier extends Notifier { return false; } - _identityExportCommunity = community; + // Keep the authorized snapshot and exported payload in the same key + // representation used by RelayConfig for authentication. + _identityExportCommunity = community.copyWith(nsec: community.nsec?.trim()); _identityExportBiometricOnly = biometricOnly; _identityExportAuthorizedAt = ref.read(identityExportClockProvider)(); state = state.copyWith(authorizationInProgress: false); diff --git a/mobile/lib/shared/read_state/read_state_provider.dart b/mobile/lib/shared/read_state/read_state_provider.dart index a1965e1b4b..1059525b1a 100644 --- a/mobile/lib/shared/read_state/read_state_provider.dart +++ b/mobile/lib/shared/read_state/read_state_provider.dart @@ -76,7 +76,7 @@ class ReadStateNotifier extends Notifier { final relayConfig = ref.watch(relayConfigProvider); ref.watch(relaySessionProvider); - final activeCommunity = ref.watch(activeCommunityProvider).value; + ref.watch(activeCommunityProvider); final nsec = relayConfig.nsec?.trim(); if (nsec == null || nsec.isEmpty) { @@ -87,14 +87,9 @@ class ReadStateNotifier extends Notifier { session: ref.read(relaySessionProvider.notifier), nsec: nsec, ); - final pubkey = - _normalizePubkey(activeCommunity?.pubkey) ?? - _safeDerivedPubkey(signedRelay); - if (pubkey == null) { - return const ReadStateState.inert(); - } - - final crypto = ReadStateCrypto.tryCreate(nsec: nsec, pubkey: pubkey); + final pubkey = ref.watch(_readStatePubkeyProvider); + if (pubkey == null) return const ReadStateState.inert(); + final crypto = ref.watch(readStateCryptoProvider); if (crypto == null) { return const ReadStateState.inert(); } @@ -247,11 +242,18 @@ String? _normalizePubkey(String? value) { return normalized; } -String? _safeDerivedPubkey(SignedEventRelay relay) { - try { - return _normalizePubkey(relay.pubkey); - } catch (e) { - debugPrint('[ReadStateManager] pubkey derivation failed: $e'); - return null; - } -} +final _readStatePubkeyProvider = Provider((ref) { + final active = ref.watch(activeCommunityProvider).value; + final config = ref.watch(relayConfigProvider); + return _normalizePubkey(active?.pubkey) ?? + _normalizePubkey(pubkeyFromNsec(config.nsec?.trim())); +}); + +/// Retains read-state encryption material across connection status changes. +final readStateCryptoProvider = Provider((ref) { + final config = ref.watch(relayConfigProvider); + final pubkey = ref.watch(_readStatePubkeyProvider); + final nsec = config.nsec?.trim(); + if (nsec == null || nsec.isEmpty || pubkey == null) return null; + return ReadStateCrypto.tryCreate(nsec: nsec, pubkey: pubkey); +}); diff --git a/mobile/lib/shared/relay/relay_provider.dart b/mobile/lib/shared/relay/relay_provider.dart index c60f57bb29..3334e618cf 100644 --- a/mobile/lib/shared/relay/relay_provider.dart +++ b/mobile/lib/shared/relay/relay_provider.dart @@ -11,13 +11,17 @@ import 'relay_client.dart'; /// - `baseUrl` — where the relay lives (used for WS + media upload) /// - `nsec` — the user's signing key (drives NIP-42 AUTH and event sigs) class RelayConfig { - const RelayConfig({required String baseUrl, this.nsec}) : _baseUrl = baseUrl; + const RelayConfig({required String baseUrl, String? nsec}) + : _baseUrl = baseUrl, + _nsec = nsec; /// Relay origin exactly as the active community stored it. final String _baseUrl; - /// Nostr secret key (bech32 nsec) for signing events and NIP-42 AUTH. - final String? nsec; + /// Normalized Nostr secret key shared by identity, signing, and NIP-42 AUTH. + String? get nsec => _nsec?.trim(); + + final String? _nsec; /// The origin as persisted, before scheme canonicalization. /// diff --git a/mobile/lib/shared/theme/community_theme_provider.dart b/mobile/lib/shared/theme/community_theme_provider.dart index 3fdeacd864..73db027527 100644 --- a/mobile/lib/shared/theme/community_theme_provider.dart +++ b/mobile/lib/shared/theme/community_theme_provider.dart @@ -30,7 +30,7 @@ class CommunityThemeNotifier extends Notifier { _storage = ref.watch(communityThemeStorageProvider); final config = ref.watch(relayConfigProvider); final session = ref.watch(relaySessionProvider); - final pubkey = pubkeyFromNsec(config.nsec); + final pubkey = ref.watch(myPubkeyProvider); _pubkey = pubkey; _relayUrl = config.baseUrl; @@ -65,7 +65,7 @@ class CommunityThemeNotifier extends Notifier { session: ref.read(relaySessionProvider.notifier), nsec: config.nsec!, ), - crypto: _crypto(config.nsec!, pubkey), + crypto: ref.watch(communityThemeCryptoProvider)!, onRemote: (remote) => _applyRemote(manager, remote), onPublished: (preference) { if (_scopedLocalPubkey == pubkey && @@ -216,14 +216,18 @@ class CommunityThemeNotifier extends Notifier { } } -CommunityThemeCrypto _crypto(String nsec, String pubkey) { - final privateHex = nostr.Nip19.decode(payload: nsec).data; +/// Retains theme encryption material while the same community reconnects. +final communityThemeCryptoProvider = Provider((ref) { + final config = ref.watch(relayConfigProvider); + final pubkey = ref.watch(myPubkeyProvider); + if (pubkey == null || config.nsec == null) return null; + final privateHex = nostr.Nip19.decode(payload: config.nsec!).data; final key = getConversationKey(privateHex, pubkey); return CommunityThemeCrypto( encrypt: (plaintext) => nip44Encrypt(key, plaintext), decrypt: (ciphertext) => nip44Decrypt(key, ciphertext), ); -} +}); final communityThemeStorageProvider = Provider( (ref) => CommunityThemeStorage(ref.watch(savedPrefsProvider)), diff --git a/mobile/test/features/pairing/pairing_provider_test.dart b/mobile/test/features/pairing/pairing_provider_test.dart index bbece7916d..42379ab795 100644 --- a/mobile/test/features/pairing/pairing_provider_test.dart +++ b/mobile/test/features/pairing/pairing_provider_test.dart @@ -826,52 +826,64 @@ void main() { expect(authorizer.lastIdentityBiometricOnly, isFalse); }); - test( - 'matching SAS sends nsec and successful completion finishes', - () async { - expect( - await notifier.authorizeIdentityExport( - community: _exportCommunity(SensitiveActionPolicy.disabledByUser), - ), - isTrue, - ); - await notifier.pair(recoveryCode); - notifier.confirmSas(); - expect(container.read(pairingProvider).userConfirmedSas, isTrue); + for (final padded in [false, true]) { + test( + 'matching SAS exports normalized nsec (padded: $padded)', + () async { + final community = + _exportCommunity(SensitiveActionPolicy.disabledByUser).copyWith( + nsec: padded + ? ' \n${_RecoveryRelayConfig.nsec}\t ' + : _RecoveryRelayConfig.nsec, + ); + container + .read(relayConfigProvider.notifier) + .update(baseUrl: community.relayUrl, nsec: community.nsec); + expect( + await notifier.authorizeIdentityExport(community: community), + isTrue, + ); + await notifier.pair(recoveryCode); + notifier.confirmSas(); + expect(container.read(pairingProvider).userConfirmedSas, isTrue); - socket.sendSourceMessage( - sourceSecret: sourceSecret, - sessionSecretHex: sessionSecretHex, - message: {'type': 'sas-confirm'}, - includeTranscriptHash: true, - ); + socket.sendSourceMessage( + sourceSecret: sourceSecret, + sessionSecretHex: sessionSecretHex, + message: {'type': 'sas-confirm'}, + includeTranscriptHash: true, + ); - await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); - expect( - container.read(pairingProvider).status, - PairingStatus.transferring, - ); - expect(authorizer.calls, 1); - final sentMessages = socket.decryptedPublishedMessages(sourceSecret); - expect( - sentMessages.any( - (message) => - message['type'] == 'payload' && - message['payload_type'] == 'nsec' && - message['payload'] == _RecoveryRelayConfig.nsec, - ), - isTrue, - ); + expect( + container.read(pairingProvider).status, + PairingStatus.transferring, + ); + expect(container.read(pairingProvider).errorMessage, isNull); + expect(authorizer.calls, 1); + final sentMessages = socket.decryptedPublishedMessages( + sourceSecret, + ); + final payloads = sentMessages.where( + (message) => message['type'] == 'payload', + ); + expect(payloads, hasLength(1)); + expect(payloads.single['payload_type'], 'nsec'); + expect(payloads.single['payload'], _RecoveryRelayConfig.nsec); - socket.sendSourceMessage( - sourceSecret: sourceSecret, - sessionSecretHex: sessionSecretHex, - message: {'type': 'complete', 'success': true}, - ); - expect(container.read(pairingProvider).status, PairingStatus.success); - }, - ); + socket.sendSourceMessage( + sourceSecret: sourceSecret, + sessionSecretHex: sessionSecretHex, + message: {'type': 'complete', 'success': true}, + ); + expect( + container.read(pairingProvider).status, + PairingStatus.success, + ); + }, + ); + } test( 'malformed payload invalidates authorization before another session', @@ -1098,50 +1110,56 @@ void main() { }, ); - for (final expired in [false, true]) { - test( - 'community switch aborts ${expired ? 'expired' : 'fresh'} export grant', - () async { - expect( - await notifier.authorizeIdentityExport( - community: _exportCommunity( - SensitiveActionPolicy.disabledByUser, + for (final change in ['community', 'key']) { + for (final expired in [false, true]) { + test( + '$change switch aborts ${expired ? 'expired' : 'fresh'} export grant', + () async { + expect( + await notifier.authorizeIdentityExport( + community: _exportCommunity( + SensitiveActionPolicy.disabledByUser, + ), ), - ), - isTrue, - ); - await notifier.pair(recoveryCode); - if (expired) now = now.add(identityExportAuthorizationTtl); - container - .read(relayConfigProvider.notifier) - .update( - baseUrl: 'https://other-relay.test', - nsec: nostr.Keys( - '2222222222222222222222222222222222222222222222222222222222222222', - ).nsec, - ); - notifier.confirmSas(); - socket.sendSourceMessage( - sourceSecret: sourceSecret, - sessionSecretHex: sessionSecretHex, - message: {'type': 'sas-confirm'}, - includeTranscriptHash: true, - ); - await Future.delayed(Duration.zero); + isTrue, + ); + await notifier.pair(recoveryCode); + if (expired) now = now.add(identityExportAuthorizationTtl); + container + .read(relayConfigProvider.notifier) + .update( + baseUrl: change == 'community' + ? 'https://other-relay.test' + : 'https://relay.test', + nsec: change == 'community' + ? _RecoveryRelayConfig.nsec + : nostr.Keys( + '2222222222222222222222222222222222222222222222222222222222222222', + ).nsec, + ); + notifier.confirmSas(); + socket.sendSourceMessage( + sourceSecret: sourceSecret, + sessionSecretHex: sessionSecretHex, + message: {'type': 'sas-confirm'}, + includeTranscriptHash: true, + ); + await Future.delayed(Duration.zero); - final state = container.read(pairingProvider); - expect(state.status, PairingStatus.confirmingSas); - expect(state.userConfirmedSas, isFalse); - expect(state.errorMessage, contains('active community changed')); - expect(authorizer.calls, 1); - expect( - socket - .decryptedPublishedMessages(sourceSecret) - .any((message) => message['type'] == 'payload'), - isFalse, - ); - }, - ); + final state = container.read(pairingProvider); + expect(state.status, PairingStatus.confirmingSas); + expect(state.userConfirmedSas, isFalse); + expect(state.errorMessage, contains('active community changed')); + expect(authorizer.calls, 1); + expect( + socket + .decryptedPublishedMessages(sourceSecret) + .any((message) => message['type'] == 'payload'), + isFalse, + ); + }, + ); + } } test('expired biometric-only authorization preserves its mode', () async { diff --git a/mobile/test/shared/crypto/reconnect_crypto_test.dart b/mobile/test/shared/crypto/reconnect_crypto_test.dart new file mode 100644 index 0000000000..f376d1c37f --- /dev/null +++ b/mobile/test/shared/crypto/reconnect_crypto_test.dart @@ -0,0 +1,209 @@ +import 'dart:async'; + +import 'package:buzz/features/channels/channel_mutes/channel_mutes_provider.dart'; +import 'package:buzz/features/channels/channel_sections/channel_sections_provider.dart'; +import 'package:buzz/features/channels/channel_sort/channel_sort_provider.dart'; +import 'package:buzz/features/channels/channel_stars/channel_stars_provider.dart'; +import 'package:buzz/shared/community/community_provider.dart'; +import 'package:buzz/shared/read_state/read_state_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/theme/community_theme_provider.dart'; +import 'package:buzz/shared/theme/theme_provider.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + test('theme crypto and publishing use the normalized signing key', () async { + SharedPreferences.setMockInitialValues({}); + final keys = nostr.Keys.generate(); + final session = _Session(); + final container = ProviderContainer( + overrides: [ + savedPrefsProvider.overrideWithValue( + await SharedPreferences.getInstance(), + ), + relayConfigProvider.overrideWith(() => _Config(' ${keys.nsec} \n')), + relaySessionProvider.overrideWith(() => session), + ], + ); + addTearDown(container.dispose); + final listener = container.listen(communityThemeProvider, (_, _) {}); + addTearDown(listener.close); + session.transition(SessionStatus.connected); + await container.pump(); + final crypto = container.read(communityThemeCryptoProvider)!; + expect(crypto.decrypt(crypto.encrypt('theme')), 'theme'); + await session.firstPublished.future.timeout(const Duration(seconds: 5)); + expect(session.published, isNotEmpty); + for (final event in session.published) { + expect(event.pubkey, keys.public); + expect(nostr.Event.fromMap(event.toJson()).isValid(), isTrue); + } + }); + + test( + 'reconnect reuses crypto but a community or key change replaces it', + () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final keys = nostr.Keys.generate(); + final observer = _CryptoObserver(); + final session = _Session(); + final container = ProviderContainer( + observers: [observer], + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + relayConfigProvider.overrideWith(() => _Config(keys.nsec)), + relaySessionProvider.overrideWith(() => session), + activeCommunityProvider.overrideWith((ref) async => null), + appLifecycleProvider.overrideWith(_Lifecycle.new), + ], + ); + addTearDown(container.dispose); + await container.read(activeCommunityProvider.future); + final listeners = [ + container.listen(channelStarsProvider, (_, _) {}), + container.listen(channelMutesProvider, (_, _) {}), + container.listen(channelSectionsProvider, (_, _) {}), + container.listen(channelSortProvider, (_, _) {}), + container.listen(readStateProvider, (_, _) {}), + container.listen(communityThemeProvider, (_, _) {}), + ]; + addTearDown(() { + for (final listener in listeners) { + listener.close(); + } + }); + await container.pump(); + session.transition(SessionStatus.connected); + await container.pump(); + expect(observer.creations, 6); + final initial = _cryptoProviders.map(container.read).toList(); + final creations = observer.creations; + expect(creations, 6); + + for (final status in [ + SessionStatus.connected, + SessionStatus.disconnected, + SessionStatus.connecting, + SessionStatus.connected, + ]) { + session.transition(status); + await container.pump(); + for (var i = 0; i < initial.length; i++) { + expect(container.read(_cryptoProviders[i]), same(initial[i])); + } + } + expect(observer.creations, creations); + final first = container.read(channelStarsCryptoProvider)!; + final ciphertext = first.encrypt('retained preference'); + expect( + container.read(channelStarsCryptoProvider)!.decrypt(ciphertext), + 'retained preference', + ); + + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://other.example', nsec: keys.nsec); + await container.pump(); + for (var i = 0; i < initial.length; i++) { + expect(container.read(_cryptoProviders[i]), isNot(same(initial[i]))); + } + // Same identity still decrypts its protocol data after a community switch. + expect( + container.read(channelStarsCryptoProvider)!.decrypt(ciphertext), + 'retained preference', + ); + + container + .read(relayConfigProvider.notifier) + .update( + baseUrl: 'https://other.example', + nsec: nostr.Keys.generate().nsec, + ); + await container.pump(); + expect( + () => container.read(channelStarsCryptoProvider)!.decrypt(ciphertext), + throwsA(anything), + ); + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://other.example'); + await container.pump(); + for (final provider in _cryptoProviders) { + expect(container.read(provider), isNull); + } + }, + ); +} + +final _cryptoProviders = >[ + channelStarsCryptoProvider, + channelMutesCryptoProvider, + channelSectionsCryptoProvider, + channelSortCryptoProvider, + readStateCryptoProvider, + communityThemeCryptoProvider, +]; + +final class _CryptoObserver extends ProviderObserver { + int creations = 0; + @override + void didAddProvider(ProviderObserverContext context, Object? value) { + if (_cryptoProviders.contains(context.provider)) creations++; + } + + @override + void didUpdateProvider( + ProviderObserverContext context, + Object? previousValue, + Object? newValue, + ) { + if (_cryptoProviders.contains(context.provider)) creations++; + } +} + +class _Config extends RelayConfigNotifier { + _Config(this.nsec); + final String nsec; + @override + RelayConfig build() => + RelayConfig(baseUrl: 'https://relay.example', nsec: nsec); +} + +class _Session extends RelaySessionNotifier { + final published = []; + final firstPublished = Completer(); + @override + SessionState build() => + const SessionState(status: SessionStatus.disconnected); + void transition(SessionStatus status) => state = SessionState(status: status); + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async => []; + @override + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String)? onClosed, + }) async => () {}; + @override + Future publish( + NostrEvent event, { + Duration timeout = const Duration(seconds: 8), + }) async { + published.add(event); + if (!firstPublished.isCompleted) firstPublished.complete(); + return event; + } +} + +class _Lifecycle extends AppLifecycleNotifier { + @override + AppLifecycleState build() => AppLifecycleState.resumed; +} diff --git a/mobile/test/shared/relay/relay_config_auth_test.dart b/mobile/test/shared/relay/relay_config_auth_test.dart new file mode 100644 index 0000000000..f9ba255e15 --- /dev/null +++ b/mobile/test/shared/relay/relay_config_auth_test.dart @@ -0,0 +1,54 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:buzz/shared/relay/relay_provider.dart'; +import 'package:buzz/shared/relay/relay_socket.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nostr/nostr.dart' as nostr; + +void main() { + test('configured padded key completes real NIP-42 authentication', () async { + final keys = nostr.Keys.generate(); + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() => server.close(force: true)); + final received = Completer(); + server.listen((request) async { + final peer = await WebSocketTransformer.upgrade(request); + addTearDown(peer.close); + peer.listen((frame) { + final data = jsonDecode(frame as String) as List; + if (data.first != 'AUTH') return; + final event = nostr.Event.fromMap( + Map.from(data[1] as Map), + ); + received.complete(event); + peer.add(jsonEncode(['OK', event.id, true, ''])); + }); + peer.add(jsonEncode(['AUTH', 'synthetic-challenge'])); + }); + final config = RelayConfig( + baseUrl: 'http://127.0.0.1:${server.port}', + nsec: ' ${keys.nsec} \n', + ); + final socket = RelaySocket( + wsUrl: config.wsUrl, + nsec: config.nsec, + onMessage: (_) {}, + onConnected: () {}, + onDisconnected: (_) {}, + ); + addTearDown(socket.dispose); + await socket.connect().timeout(const Duration(seconds: 5)); + final event = await received.future.timeout(const Duration(seconds: 5)); + expect(socket.state, SocketState.connected); + expect(event.pubkey, keys.public); + expect(event.kind, 22242); + expect( + event.toMap()['tags'], + contains(equals(['challenge', 'synthetic-challenge'])), + ); + expect(event.toMap()['tags'], contains(equals(['relay', config.wsUrl]))); + expect(event.isValid(), isTrue); + }); +}