From a93f3282cc017e8decc8ea803d92967c4c3e36df Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Tue, 20 Oct 2026 14:57:53 -0700 Subject: [PATCH] perf(mobile): retain preference crypto across reconnects (#8442) Foreground reconnects rebuild preference managers and repeatedly derive the same NIP-44 keys on the UI isolate. Retain the six crypto objects in providers scoped to relay configuration and identity while preserving manager refresh, subscription, and publication behavior. Community changes, key rotation, and sign-out invalidate the retained objects. RelayConfig normalizes the signing key once at its shared accessor, so identity, theme encryption, event signing, and NIP-42 authentication receive the same value, including when an active configuration key has surrounding whitespace. The identity-export authorization snapshot uses the same normalized key for the active-identity check and the outgoing recovery payload. The branch incorporates main's relay-scoped preference managers. ### Validation - Regression exercises six production crypto providers across reconnects, configuration/key changes, and sign-out. Reintroducing a session dependency makes it fail. - Added a loopback WebSocket regression that exercises actual RelaySocket NIP-42 authentication with a padded key and verifies its challenge, relay, pubkey and signature. It fails before shared key normalization. - Added a connected-theme regression for whitespace-padded keys, encryption round-trip, and a valid published event signature. It fails before the normalization fix. - Added padded-key identity export coverage through authorization, SAS confirmation, exactly one normalized payload, and successful completion. Separate relay-change and key-rotation tests verify both fresh and expired grants fail closed. The padded-key regression failed before the fix. - Current head: all 48 focused pairing/crypto/auth tests and mobile analysis passed. All [CI checks](https://github.com/block/buzz/actions/runs/39497098787) passed, including mobile test shards, Android, native Swift tests and release builds. Codex feedback is addressed; Jude approved this exact head after independent regression and mutation checks. Fresh [Codex security review](https://github.com/block/buzz/pull/8442#issuecomment-6300273942) found no concrete issues. - Previous head: 122 config/socket/crypto/theme tests, 44 read-state tests, and all [GitHub CI checks](https://github.com/block/buzz/actions/runs/39491550425) passed, including mobile test shards, Android, native Swift tests and release builds. - Original implementation: native iOS Debug build and full local repository checks passed. A paired simulator background/resume capture had no samples in the targeted crypto constructors or key derivation; an 82 ms UI frame remained. Debug/JIT, profiler overhead and shared-host activity prevent an overall speedup claim. The later normalization change is covered by the production-provider regression above. - Two image decode timing failures occurred in the initial local full suite; direct baseline/patch runs, the subsequent full repository run, and the latest full mobile suite passed. Cold-start credential validation is unchanged: padded persisted keys are rejected by AuthNotifier before RelayConfig is constructed. The normalization regressions cover active configurations and identity export, not broader persisted-key acceptance. --------- Signed-off-by: Tom Brow --- .../channel_mutes/channel_mutes_provider.dart | 27 +-- .../channel_sections_provider.dart | 27 +-- .../channel_sort/channel_sort_provider.dart | 27 +-- .../channel_stars/channel_stars_provider.dart | 27 +-- .../features/pairing/pairing_provider.dart | 4 +- .../read_state/read_state_provider.dart | 36 +-- mobile/lib/shared/relay/relay_provider.dart | 10 +- .../theme/community_theme_provider.dart | 14 +- .../pairing/pairing_provider_test.dart | 186 +++++++++------- .../shared/crypto/reconnect_crypto_test.dart | 209 ++++++++++++++++++ .../shared/relay/relay_config_auth_test.dart | 54 +++++ 11 files changed, 451 insertions(+), 170 deletions(-) create mode 100644 mobile/test/shared/crypto/reconnect_crypto_test.dart create mode 100644 mobile/test/shared/relay/relay_config_auth_test.dart 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); + }); +}