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.

<!-- buzz-review-completed -->

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-10-20 14:57:53 -07:00
committed by GitHub
parent 8bbfe44f77
commit a93f3282cc
11 changed files with 451 additions and 170 deletions

View File

@@ -1,6 +1,5 @@
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import '../../../shared/relay/relay.dart'; import '../../../shared/relay/relay.dart';
import '../../../shared/theme/theme_provider.dart'; import '../../../shared/theme/theme_provider.dart';
@@ -42,15 +41,9 @@ class ChannelMutesNotifier extends Notifier<ChannelMutesState> {
return const ChannelMutesState(); return const ChannelMutesState();
} }
final pubkey = _safePubkeyFromNsec(nsec); final pubkey = ref.watch(myPubkeyProvider);
if (pubkey == null || pubkey.isEmpty) { final crypto = ref.watch(channelMutesCryptoProvider);
return const ChannelMutesState(); if (pubkey == null || crypto == null) {
}
final ChannelMutesCrypto crypto;
try {
crypto = ChannelMutesCrypto(nsec, pubkey);
} catch (_) {
return const ChannelMutesState(); return const ChannelMutesState();
} }
@@ -114,12 +107,16 @@ final channelMutesProvider =
ChannelMutesNotifier.new, 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<ChannelMutesCrypto?>((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 { try {
final privkeyHex = nostr.Nip19.decode(payload: nsec).data; return ChannelMutesCrypto(nsec, pubkey);
if (privkeyHex.isEmpty) return null;
return nostr.Keys(privkeyHex).public;
} catch (_) { } catch (_) {
return null; return null;
} }
} });

View File

@@ -1,6 +1,5 @@
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import '../../../shared/relay/relay.dart'; import '../../../shared/relay/relay.dart';
import '../../../shared/theme/theme_provider.dart'; import '../../../shared/theme/theme_provider.dart';
@@ -42,15 +41,9 @@ class ChannelSectionsNotifier extends Notifier<ChannelSectionsState> {
return const ChannelSectionsState(); return const ChannelSectionsState();
} }
final pubkey = _safePubkeyFromNsec(nsec); final pubkey = ref.watch(myPubkeyProvider);
if (pubkey == null || pubkey.isEmpty) { final crypto = ref.watch(channelSectionsCryptoProvider);
return const ChannelSectionsState(); if (pubkey == null || crypto == null) {
}
final ChannelSectionsCrypto crypto;
try {
crypto = ChannelSectionsCrypto(nsec, pubkey);
} catch (_) {
return const ChannelSectionsState(); return const ChannelSectionsState();
} }
@@ -132,12 +125,16 @@ final channelSectionsProvider =
ChannelSectionsNotifier.new, 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<ChannelSectionsCrypto?>((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 { try {
final privkeyHex = nostr.Nip19.decode(payload: nsec).data; return ChannelSectionsCrypto(nsec, pubkey);
if (privkeyHex.isEmpty) return null;
return nostr.Keys(privkeyHex).public;
} catch (_) { } catch (_) {
return null; return null;
} }
} });

View File

@@ -1,6 +1,5 @@
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import '../../../shared/relay/relay.dart'; import '../../../shared/relay/relay.dart';
import '../../../shared/theme/theme_provider.dart'; import '../../../shared/theme/theme_provider.dart';
@@ -44,15 +43,9 @@ class ChannelSortNotifier extends Notifier<ChannelSortState> {
return const ChannelSortState(); return const ChannelSortState();
} }
final pubkey = _safePubkeyFromNsec(nsec); final pubkey = ref.watch(myPubkeyProvider);
if (pubkey == null || pubkey.isEmpty) { final crypto = ref.watch(channelSortCryptoProvider);
return const ChannelSortState(); if (pubkey == null || crypto == null) {
}
final ChannelSortCrypto crypto;
try {
crypto = ChannelSortCrypto(nsec, pubkey);
} catch (_) {
return const ChannelSortState(); return const ChannelSortState();
} }
@@ -124,12 +117,16 @@ final channelSortProvider =
ChannelSortNotifier.new, 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<ChannelSortCrypto?>((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 { try {
final privkeyHex = nostr.Nip19.decode(payload: nsec).data; return ChannelSortCrypto(nsec, pubkey);
if (privkeyHex.isEmpty) return null;
return nostr.Keys(privkeyHex).public;
} catch (_) { } catch (_) {
return null; return null;
} }
} });

View File

@@ -1,6 +1,5 @@
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import '../../../shared/relay/relay.dart'; import '../../../shared/relay/relay.dart';
import '../../../shared/theme/theme_provider.dart'; import '../../../shared/theme/theme_provider.dart';
@@ -42,15 +41,9 @@ class ChannelStarsNotifier extends Notifier<ChannelStarsState> {
return const ChannelStarsState(); return const ChannelStarsState();
} }
final pubkey = _safePubkeyFromNsec(nsec); final pubkey = ref.watch(myPubkeyProvider);
if (pubkey == null || pubkey.isEmpty) { final crypto = ref.watch(channelStarsCryptoProvider);
return const ChannelStarsState(); if (pubkey == null || crypto == null) {
}
final ChannelStarsCrypto crypto;
try {
crypto = ChannelStarsCrypto(nsec, pubkey);
} catch (_) {
return const ChannelStarsState(); return const ChannelStarsState();
} }
@@ -114,12 +107,16 @@ final channelStarsProvider =
ChannelStarsNotifier.new, 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<ChannelStarsCrypto?>((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 { try {
final privkeyHex = nostr.Nip19.decode(payload: nsec).data; return ChannelStarsCrypto(nsec, pubkey);
if (privkeyHex.isEmpty) return null;
return nostr.Keys(privkeyHex).public;
} catch (_) { } catch (_) {
return null; return null;
} }
} });

View File

@@ -121,7 +121,9 @@ class PairingNotifier extends Notifier<PairingState> {
return false; 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; _identityExportBiometricOnly = biometricOnly;
_identityExportAuthorizedAt = ref.read(identityExportClockProvider)(); _identityExportAuthorizedAt = ref.read(identityExportClockProvider)();
state = state.copyWith(authorizationInProgress: false); state = state.copyWith(authorizationInProgress: false);

View File

@@ -76,7 +76,7 @@ class ReadStateNotifier extends Notifier<ReadStateState> {
final relayConfig = ref.watch(relayConfigProvider); final relayConfig = ref.watch(relayConfigProvider);
ref.watch(relaySessionProvider); ref.watch(relaySessionProvider);
final activeCommunity = ref.watch(activeCommunityProvider).value; ref.watch(activeCommunityProvider);
final nsec = relayConfig.nsec?.trim(); final nsec = relayConfig.nsec?.trim();
if (nsec == null || nsec.isEmpty) { if (nsec == null || nsec.isEmpty) {
@@ -87,14 +87,9 @@ class ReadStateNotifier extends Notifier<ReadStateState> {
session: ref.read(relaySessionProvider.notifier), session: ref.read(relaySessionProvider.notifier),
nsec: nsec, nsec: nsec,
); );
final pubkey = final pubkey = ref.watch(_readStatePubkeyProvider);
_normalizePubkey(activeCommunity?.pubkey) ?? if (pubkey == null) return const ReadStateState.inert();
_safeDerivedPubkey(signedRelay); final crypto = ref.watch(readStateCryptoProvider);
if (pubkey == null) {
return const ReadStateState.inert();
}
final crypto = ReadStateCrypto.tryCreate(nsec: nsec, pubkey: pubkey);
if (crypto == null) { if (crypto == null) {
return const ReadStateState.inert(); return const ReadStateState.inert();
} }
@@ -247,11 +242,18 @@ String? _normalizePubkey(String? value) {
return normalized; return normalized;
} }
String? _safeDerivedPubkey(SignedEventRelay relay) { final _readStatePubkeyProvider = Provider<String?>((ref) {
try { final active = ref.watch(activeCommunityProvider).value;
return _normalizePubkey(relay.pubkey); final config = ref.watch(relayConfigProvider);
} catch (e) { return _normalizePubkey(active?.pubkey) ??
debugPrint('[ReadStateManager] pubkey derivation failed: $e'); _normalizePubkey(pubkeyFromNsec(config.nsec?.trim()));
return null; });
}
} /// Retains read-state encryption material across connection status changes.
final readStateCryptoProvider = Provider<ReadStateCrypto?>((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);
});

View File

@@ -11,13 +11,17 @@ import 'relay_client.dart';
/// - `baseUrl` — where the relay lives (used for WS + media upload) /// - `baseUrl` — where the relay lives (used for WS + media upload)
/// - `nsec` — the user's signing key (drives NIP-42 AUTH and event sigs) /// - `nsec` — the user's signing key (drives NIP-42 AUTH and event sigs)
class RelayConfig { 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. /// Relay origin exactly as the active community stored it.
final String _baseUrl; final String _baseUrl;
/// Nostr secret key (bech32 nsec) for signing events and NIP-42 AUTH. /// Normalized Nostr secret key shared by identity, signing, and NIP-42 AUTH.
final String? nsec; String? get nsec => _nsec?.trim();
final String? _nsec;
/// The origin as persisted, before scheme canonicalization. /// The origin as persisted, before scheme canonicalization.
/// ///

View File

@@ -30,7 +30,7 @@ class CommunityThemeNotifier extends Notifier<CommunityThemePreference> {
_storage = ref.watch(communityThemeStorageProvider); _storage = ref.watch(communityThemeStorageProvider);
final config = ref.watch(relayConfigProvider); final config = ref.watch(relayConfigProvider);
final session = ref.watch(relaySessionProvider); final session = ref.watch(relaySessionProvider);
final pubkey = pubkeyFromNsec(config.nsec); final pubkey = ref.watch(myPubkeyProvider);
_pubkey = pubkey; _pubkey = pubkey;
_relayUrl = config.baseUrl; _relayUrl = config.baseUrl;
@@ -65,7 +65,7 @@ class CommunityThemeNotifier extends Notifier<CommunityThemePreference> {
session: ref.read(relaySessionProvider.notifier), session: ref.read(relaySessionProvider.notifier),
nsec: config.nsec!, nsec: config.nsec!,
), ),
crypto: _crypto(config.nsec!, pubkey), crypto: ref.watch(communityThemeCryptoProvider)!,
onRemote: (remote) => _applyRemote(manager, remote), onRemote: (remote) => _applyRemote(manager, remote),
onPublished: (preference) { onPublished: (preference) {
if (_scopedLocalPubkey == pubkey && if (_scopedLocalPubkey == pubkey &&
@@ -216,14 +216,18 @@ class CommunityThemeNotifier extends Notifier<CommunityThemePreference> {
} }
} }
CommunityThemeCrypto _crypto(String nsec, String pubkey) { /// Retains theme encryption material while the same community reconnects.
final privateHex = nostr.Nip19.decode(payload: nsec).data; final communityThemeCryptoProvider = Provider<CommunityThemeCrypto?>((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); final key = getConversationKey(privateHex, pubkey);
return CommunityThemeCrypto( return CommunityThemeCrypto(
encrypt: (plaintext) => nip44Encrypt(key, plaintext), encrypt: (plaintext) => nip44Encrypt(key, plaintext),
decrypt: (ciphertext) => nip44Decrypt(key, ciphertext), decrypt: (ciphertext) => nip44Decrypt(key, ciphertext),
); );
} });
final communityThemeStorageProvider = Provider<CommunityThemeStorage>( final communityThemeStorageProvider = Provider<CommunityThemeStorage>(
(ref) => CommunityThemeStorage(ref.watch(savedPrefsProvider)), (ref) => CommunityThemeStorage(ref.watch(savedPrefsProvider)),

View File

@@ -826,52 +826,64 @@ void main() {
expect(authorizer.lastIdentityBiometricOnly, isFalse); expect(authorizer.lastIdentityBiometricOnly, isFalse);
}); });
test( for (final padded in [false, true]) {
'matching SAS sends nsec and successful completion finishes', test(
() async { 'matching SAS exports normalized nsec (padded: $padded)',
expect( () async {
await notifier.authorizeIdentityExport( final community =
community: _exportCommunity(SensitiveActionPolicy.disabledByUser), _exportCommunity(SensitiveActionPolicy.disabledByUser).copyWith(
), nsec: padded
isTrue, ? ' \n${_RecoveryRelayConfig.nsec}\t '
); : _RecoveryRelayConfig.nsec,
await notifier.pair(recoveryCode); );
notifier.confirmSas(); container
expect(container.read(pairingProvider).userConfirmedSas, isTrue); .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( socket.sendSourceMessage(
sourceSecret: sourceSecret, sourceSecret: sourceSecret,
sessionSecretHex: sessionSecretHex, sessionSecretHex: sessionSecretHex,
message: {'type': 'sas-confirm'}, message: {'type': 'sas-confirm'},
includeTranscriptHash: true, includeTranscriptHash: true,
); );
await Future<void>.delayed(Duration.zero); await Future<void>.delayed(Duration.zero);
expect( expect(
container.read(pairingProvider).status, container.read(pairingProvider).status,
PairingStatus.transferring, PairingStatus.transferring,
); );
expect(authorizer.calls, 1); expect(container.read(pairingProvider).errorMessage, isNull);
final sentMessages = socket.decryptedPublishedMessages(sourceSecret); expect(authorizer.calls, 1);
expect( final sentMessages = socket.decryptedPublishedMessages(
sentMessages.any( sourceSecret,
(message) => );
message['type'] == 'payload' && final payloads = sentMessages.where(
message['payload_type'] == 'nsec' && (message) => message['type'] == 'payload',
message['payload'] == _RecoveryRelayConfig.nsec, );
), expect(payloads, hasLength(1));
isTrue, expect(payloads.single['payload_type'], 'nsec');
); expect(payloads.single['payload'], _RecoveryRelayConfig.nsec);
socket.sendSourceMessage( socket.sendSourceMessage(
sourceSecret: sourceSecret, sourceSecret: sourceSecret,
sessionSecretHex: sessionSecretHex, sessionSecretHex: sessionSecretHex,
message: {'type': 'complete', 'success': true}, message: {'type': 'complete', 'success': true},
); );
expect(container.read(pairingProvider).status, PairingStatus.success); expect(
}, container.read(pairingProvider).status,
); PairingStatus.success,
);
},
);
}
test( test(
'malformed payload invalidates authorization before another session', 'malformed payload invalidates authorization before another session',
@@ -1098,50 +1110,56 @@ void main() {
}, },
); );
for (final expired in [false, true]) { for (final change in ['community', 'key']) {
test( for (final expired in [false, true]) {
'community switch aborts ${expired ? 'expired' : 'fresh'} export grant', test(
() async { '$change switch aborts ${expired ? 'expired' : 'fresh'} export grant',
expect( () async {
await notifier.authorizeIdentityExport( expect(
community: _exportCommunity( await notifier.authorizeIdentityExport(
SensitiveActionPolicy.disabledByUser, community: _exportCommunity(
SensitiveActionPolicy.disabledByUser,
),
), ),
), isTrue,
isTrue, );
); await notifier.pair(recoveryCode);
await notifier.pair(recoveryCode); if (expired) now = now.add(identityExportAuthorizationTtl);
if (expired) now = now.add(identityExportAuthorizationTtl); container
container .read(relayConfigProvider.notifier)
.read(relayConfigProvider.notifier) .update(
.update( baseUrl: change == 'community'
baseUrl: 'https://other-relay.test', ? 'https://other-relay.test'
nsec: nostr.Keys( : 'https://relay.test',
'2222222222222222222222222222222222222222222222222222222222222222', nsec: change == 'community'
).nsec, ? _RecoveryRelayConfig.nsec
); : nostr.Keys(
notifier.confirmSas(); '2222222222222222222222222222222222222222222222222222222222222222',
socket.sendSourceMessage( ).nsec,
sourceSecret: sourceSecret, );
sessionSecretHex: sessionSecretHex, notifier.confirmSas();
message: {'type': 'sas-confirm'}, socket.sendSourceMessage(
includeTranscriptHash: true, sourceSecret: sourceSecret,
); sessionSecretHex: sessionSecretHex,
await Future<void>.delayed(Duration.zero); message: {'type': 'sas-confirm'},
includeTranscriptHash: true,
);
await Future<void>.delayed(Duration.zero);
final state = container.read(pairingProvider); final state = container.read(pairingProvider);
expect(state.status, PairingStatus.confirmingSas); expect(state.status, PairingStatus.confirmingSas);
expect(state.userConfirmedSas, isFalse); expect(state.userConfirmedSas, isFalse);
expect(state.errorMessage, contains('active community changed')); expect(state.errorMessage, contains('active community changed'));
expect(authorizer.calls, 1); expect(authorizer.calls, 1);
expect( expect(
socket socket
.decryptedPublishedMessages(sourceSecret) .decryptedPublishedMessages(sourceSecret)
.any((message) => message['type'] == 'payload'), .any((message) => message['type'] == 'payload'),
isFalse, isFalse,
); );
}, },
); );
}
} }
test('expired biometric-only authorization preserves its mode', () async { test('expired biometric-only authorization preserves its mode', () async {

View File

@@ -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 = <Provider<Object?>>[
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 = <NostrEvent>[];
final firstPublished = Completer<void>();
@override
SessionState build() =>
const SessionState(status: SessionStatus.disconnected);
void transition(SessionStatus status) => state = SessionState(status: status);
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async => [];
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String)? onClosed,
}) async => () {};
@override
Future<NostrEvent> 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;
}

View File

@@ -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<nostr.Event>();
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<String, dynamic>.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);
});
}