mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
209
mobile/test/shared/crypto/reconnect_crypto_test.dart
Normal file
209
mobile/test/shared/crypto/reconnect_crypto_test.dart
Normal 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;
|
||||
}
|
||||
54
mobile/test/shared/relay/relay_config_auth_test.dart
Normal file
54
mobile/test/shared/relay/relay_config_auth_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user