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: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<ChannelMutesState> {
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<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 {
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;
}
}
});

View File

@@ -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<ChannelSectionsState> {
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<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 {
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;
}
}
});

View File

@@ -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<ChannelSortState> {
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<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 {
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;
}
}
});

View File

@@ -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<ChannelStarsState> {
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<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 {
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;
}
}
});

View File

@@ -121,7 +121,9 @@ class PairingNotifier extends Notifier<PairingState> {
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);

View File

@@ -76,7 +76,7 @@ class ReadStateNotifier extends Notifier<ReadStateState> {
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<ReadStateState> {
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<String?>((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<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)
/// - `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.
///

View File

@@ -30,7 +30,7 @@ class CommunityThemeNotifier extends Notifier<CommunityThemePreference> {
_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<CommunityThemePreference> {
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<CommunityThemePreference> {
}
}
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<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);
return CommunityThemeCrypto(
encrypt: (plaintext) => nip44Encrypt(key, plaintext),
decrypt: (ciphertext) => nip44Decrypt(key, ciphertext),
);
}
});
final communityThemeStorageProvider = Provider<CommunityThemeStorage>(
(ref) => CommunityThemeStorage(ref.watch(savedPrefsProvider)),

View File

@@ -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<void>.delayed(Duration.zero);
await Future<void>.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<void>.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<void>.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 {

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);
});
}