mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
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