perf(mobile): backport preference crypto reuse to 0.25 (#8502)

Backport the final merged preference-crypto fix (#8442) onto
`mobile-v0.25` for 0.25.1 RC3, preserving RC2's emoji and DM-sort fixes
(#8501).

Reconnects reuse identity/community-scoped preference crypto instead of
repeatedly deriving the same keys. The backport also includes the
reviewed signing-key normalization and identity-export corrections from
the final main revision. It is a clean cherry-pick with an identical
patch ID; no other main changes are included. No overlapping open
stable-backport PR was found.

### Validation

- All 47 focused stable-branch crypto, NIP-42 authentication, and
pairing tests pass, including padded-key normalization and
identity-export regressions.
- Full mobile pre-push checks and full repository `just ci` pass. Codex
review completed with no findings.
- The local branch-skew hook assumes `main` and is excluded for this
stable-target backport. Required stable-branch CI and review remain
enforced.
- No new device capture or release-frame benchmark. Stable-target
security review is unavailable under the current main-only workflow
policy.

Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-10-20 16:22:48 -07:00
committed by GitHub
parent 87b1dca4a0
commit cc789b657a
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();
}
@@ -113,12 +106,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();
}
@@ -131,12 +124,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();
}
@@ -113,12 +106,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

@@ -119,7 +119,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();
}
@@ -246,11 +241,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

@@ -10,13 +10,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

@@ -791,52 +791,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',
@@ -1063,50 +1075,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);
});
}