diff --git a/mobile/lib/features/channels/mentions/mention_candidates.dart b/mobile/lib/features/channels/mentions/mention_candidates.dart index 9c4ef96bbe..8deeedf106 100644 --- a/mobile/lib/features/channels/mentions/mention_candidates.dart +++ b/mobile/lib/features/channels/mentions/mention_candidates.dart @@ -50,14 +50,22 @@ List buildMentionCandidates({ required Set sharedChannelIds, required Map userCache, required Map ownerByAgentPubkey, + Set archivedPubkeys = const {}, List searchResults = const [], String? currentPubkey, }) { final candidates = []; final seen = {}; + final currentLower = currentPubkey?.toLowerCase(); + final archived = archivedPubkeys + .map((pubkey) => pubkey.toLowerCase()) + .toSet(); + bool isArchived(String pubkey) => + pubkey != currentLower && archived.contains(pubkey); for (final member in members) { final pk = member.pubkey.toLowerCase(); + if (isArchived(pk)) continue; if (!seen.add(pk)) continue; final profile = userCache[pk]; final ownerPubkey = ownerByAgentPubkey[pk] ?? profile?.ownerPubkey; @@ -81,14 +89,17 @@ List buildMentionCandidates({ final directoryPubkeys = {}; final sharedAgentPubkeys = {}; for (final agent in relayAgents) { - directoryPubkeys.add(agent.pubkey); + final pk = agent.pubkey.toLowerCase(); + directoryPubkeys.add(pk); + if (isArchived(pk)) continue; if (agentIsSharedWithUser(agent, sharedChannelIds, currentPubkey)) { - sharedAgentPubkeys.add(agent.pubkey); + sharedAgentPubkeys.add(pk); } } for (final agent in relayAgents) { - final pk = agent.pubkey; + final pk = agent.pubkey.toLowerCase(); + if (isArchived(pk)) continue; if (seen.contains(pk)) continue; if (!sharedAgentPubkeys.contains(pk)) continue; seen.add(pk); @@ -108,9 +119,9 @@ List buildMentionCandidates({ ); } - final currentLower = currentPubkey?.toLowerCase(); for (final profile in searchResults) { final pk = profile.pubkey.toLowerCase(); + if (isArchived(pk)) continue; if (seen.contains(pk)) continue; final ownerPubkey = ownerByAgentPubkey[pk] ?? profile.ownerPubkey; final isAgent = ownerPubkey != null || directoryPubkeys.contains(pk); diff --git a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart index 6e94459231..b65f81b277 100644 --- a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart +++ b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart @@ -1,6 +1,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../../shared/crypto/nip_oa.dart'; +import '../../../shared/identity_archive/archived_identities_provider.dart'; import '../../../shared/mentions/agent_identity_provider.dart'; import '../../../shared/relay/relay.dart'; import '../../profile/user_cache_provider.dart'; @@ -80,6 +81,13 @@ final mentionCandidatesProvider = Provider.family ref.watch(agentDirectoryProvider).asData?.value ?? const []; final owners = ref.watch(agentOwnersProvider).asData?.value ?? const {}; + final archivedSnapshot = ref.watch(archivedIdentityPubkeysProvider); + // Do not briefly expose archived identities while the relay-scoped + // snapshot is loading or refreshing for a new relay. The archive + // provider itself fails open to an empty set when the relay cannot + // supply a valid snapshot. + if (archivedSnapshot.isLoading) return const []; + final archivedPubkeys = archivedSnapshot.asData?.value ?? const {}; final channels = ref.watch(channelsProvider).asData?.value ?? const []; final userCache = ref.watch(userCacheProvider); @@ -99,6 +107,7 @@ final mentionCandidatesProvider = Provider.family sharedChannelIds: sharedChannelIds, userCache: userCache, ownerByAgentPubkey: owners, + archivedPubkeys: archivedPubkeys, searchResults: searchResults, currentPubkey: currentPubkey, ); diff --git a/mobile/lib/shared/identity_archive/archived_identities_provider.dart b/mobile/lib/shared/identity_archive/archived_identities_provider.dart new file mode 100644 index 0000000000..adabd853c9 --- /dev/null +++ b/mobile/lib/shared/identity_archive/archived_identities_provider.dart @@ -0,0 +1,101 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:nostr/nostr.dart' as nostr; + +import '../relay/relay.dart'; + +final _hexPubkey = RegExp(r'^[0-9a-fA-F]{64}$'); + +final archivedIdentitiesHttpClientProvider = Provider((ref) { + final client = http.Client(); + ref.onDispose(client.close); + return client; +}); + +/// Relay-scoped archive state from the latest valid NIP-IA snapshot. +/// +/// This fails open while disconnected or when NIP-11/snapshot verification +/// fails, matching desktop's discovery predicate. +final archivedIdentityPubkeysProvider = FutureProvider>(( + ref, +) async { + final sessionState = ref.watch(relaySessionProvider); + if (sessionState.status != SessionStatus.connected) { + final connected = Completer>(); + ref.onDispose(() { + if (!connected.isCompleted) connected.complete(const {}); + }); + return connected.future; + } + + final config = ref.watch(relayConfigProvider); + try { + final response = await ref + .read(archivedIdentitiesHttpClientProvider) + .get( + Uri.parse(config.baseUrl), + headers: const {'Accept': 'application/nostr+json'}, + ) + .timeout(const Duration(seconds: 5)); + if (response.statusCode < 200 || response.statusCode >= 300) { + return const {}; + } + + final document = jsonDecode(response.body); + if (document is! Map) return const {}; + final relaySelf = document['self']; + if (relaySelf is! String || !_hexPubkey.hasMatch(relaySelf)) { + return const {}; + } + + final events = await ref.read(relaySessionProvider.notifier).queryRelay([ + NostrFilter( + kinds: const [EventKind.archivedIdentities], + authors: [relaySelf.toLowerCase()], + limit: 1, + ), + ]); + if (events.isEmpty) return const {}; + events.sort((left, right) => right.createdAt.compareTo(left.createdAt)); + return archivedPubkeysFromSnapshot(events.first, relaySelf); + } catch (_) { + return const {}; + } +}); + +/// Returns the archived pubkeys from a valid snapshot signed by [relayPubkey]. +/// Invalid or foreign snapshots fail open so unauthenticated relay state never +/// hides an identity. +Set archivedPubkeysFromSnapshot( + NostrEvent snapshot, + String relayPubkey, +) { + final relay = relayPubkey.toLowerCase(); + if (!_hexPubkey.hasMatch(relay) || + snapshot.kind != EventKind.archivedIdentities || + snapshot.pubkey.toLowerCase() != relay) { + return const {}; + } + + final nip70Tags = snapshot.tags.where( + (tag) => tag.isNotEmpty && tag.first == '-', + ); + if (nip70Tags.length != 1 || nip70Tags.single.length != 1) { + return const {}; + } + + try { + nostr.Event.fromJson(jsonEncode(snapshot.toJson())); + } catch (_) { + return const {}; + } + + return Set.unmodifiable({ + for (final tag in snapshot.tags) + if (tag.length >= 2 && tag.first == 'p' && _hexPubkey.hasMatch(tag[1])) + tag[1].toLowerCase(), + }); +} diff --git a/mobile/lib/shared/relay/nostr_models.dart b/mobile/lib/shared/relay/nostr_models.dart index 820fee4ed6..8adbb7dc95 100644 --- a/mobile/lib/shared/relay/nostr_models.dart +++ b/mobile/lib/shared/relay/nostr_models.dart @@ -16,6 +16,7 @@ abstract final class EventKind { static const typingIndicator = 20002; static const auth = 22242; static const agentObserverFrame = 24200; + static const archivedIdentities = 13535; static const huddleReaction = 24810; static const readState = 30078; static const eventReminder = 30300; diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index 919d4039eb..6b25911f6b 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -20,6 +20,7 @@ import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/photo_library.dart'; import 'package:buzz/shared/custom_emoji/custom_emoji.dart'; import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart'; +import 'package:buzz/shared/identity_archive/archived_identities_provider.dart'; import 'package:buzz/shared/mentions/agent_identity_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; @@ -170,6 +171,8 @@ Widget _buildComposeBar({ List members = const [], Future>? membersFuture, List relayAgents = const [], + Set archivedPubkeys = const {}, + Future>? archivedPubkeysFuture, List channels = const [], String? currentPubkey, bool? supportsShowingSystemContextMenu, @@ -189,6 +192,9 @@ Widget _buildComposeBar({ ).overrideWith((ref) => membersFuture ?? Future.value(members)), agentDirectoryProvider.overrideWith((ref) async => relayAgents), agentOwnersProvider.overrideWith((ref) async => const {}), + archivedIdentityPubkeysProvider.overrideWith( + (ref) => archivedPubkeysFuture ?? Future.value(archivedPubkeys), + ), relayClientProvider.overrideWithValue( RelayClient(baseUrl: 'http://localhost:3000'), ), @@ -2213,6 +2219,90 @@ void main() { ); }); + testWidgets('hides archived agents from mention suggestions', ( + tester, + ) async { + final agentPubkey = 'c' * 64; + final signer = nostr.Keys.generate(); + final uploadService = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: signer.nsec, + pickGalleryImage: () async => null, + pickGalleryVideo: () async => null, + ); + + await tester.pumpWidget( + _buildComposeBar( + uploadService: uploadService, + currentPubkey: signer.public, + archivedPubkeys: {agentPubkey}, + relayAgents: [ + AgentDirectoryEntry( + pubkey: agentPubkey, + displayName: 'Archived Helper Bot', + respondTo: 'anyone', + channelIds: const ['shared-channel'], + ), + ], + channels: [_makeCurrentChannel(), _makeSharedMemberChannel()], + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + await _expandComposer(tester); + await tester.enterText(find.byType(TextField), '@'); + await tester.pumpAndSettle(); + + expect(find.text('Archived Helper Bot'), findsNothing); + }); + + testWidgets('hides mention suggestions while archive state loads', ( + tester, + ) async { + final agentPubkey = 'c' * 64; + final signer = nostr.Keys.generate(); + final uploadService = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: signer.nsec, + pickGalleryImage: () async => null, + pickGalleryVideo: () async => null, + ); + + await tester.pumpWidget( + _buildComposeBar( + uploadService: uploadService, + currentPubkey: signer.public, + archivedPubkeysFuture: Completer>().future, + relayAgents: [ + AgentDirectoryEntry( + pubkey: agentPubkey, + displayName: 'Potentially Archived Bot', + respondTo: 'anyone', + channelIds: const ['shared-channel'], + ), + ], + channels: [_makeCurrentChannel(), _makeSharedMemberChannel()], + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + await _expandComposer(tester); + await tester.enterText(find.byType(TextField), '@'); + await tester.pump(); + + expect(find.text('Potentially Archived Bot'), findsNothing); + }); + testWidgets('adds a selected non-member agent as a bot before sending', ( tester, ) async { diff --git a/mobile/test/features/channels/mentions/mention_candidates_test.dart b/mobile/test/features/channels/mentions/mention_candidates_test.dart index 811996857c..ec464ee120 100644 --- a/mobile/test/features/channels/mentions/mention_candidates_test.dart +++ b/mobile/test/features/channels/mentions/mention_candidates_test.dart @@ -78,6 +78,28 @@ void main() { }); group('buildMentionCandidates', () { + test('excludes archived identities but keeps the current user', () { + final archivedMember = '4' * 64; + final archivedAgent = '5' * 64; + final candidates = buildMentionCandidates( + members: [member(archivedMember), member(userPubkey)], + relayAgents: [ + AgentDirectoryEntry( + pubkey: archivedAgent, + respondTo: 'anyone', + channelIds: const ['chan-1'], + ), + ], + sharedChannelIds: const {'chan-1'}, + userCache: const {}, + ownerByAgentPubkey: const {}, + archivedPubkeys: {archivedMember, archivedAgent, userPubkey}, + currentPubkey: userPubkey, + ); + + expect(candidates.map((candidate) => candidate.pubkey), [userPubkey]); + }); + test('members come first; eligible non-member agents follow', () { final candidates = buildMentionCandidates( members: [member(memberPubkey), member(userPubkey)], diff --git a/mobile/test/shared/identity_archive/archived_identities_provider_test.dart b/mobile/test/shared/identity_archive/archived_identities_provider_test.dart new file mode 100644 index 0000000000..645511c8a7 --- /dev/null +++ b/mobile/test/shared/identity_archive/archived_identities_provider_test.dart @@ -0,0 +1,205 @@ +import 'package:buzz/shared/identity_archive/archived_identities_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart' as http_testing; +import 'package:nostr/nostr.dart' as nostr; + +void main() { + test('stays loading until the relay session connects', () async { + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(_DisconnectedRelaySession.new), + ], + ); + addTearDown(container.dispose); + + final subscription = container.listen( + archivedIdentityPubkeysProvider, + (_, _) {}, + fireImmediately: true, + ); + addTearDown(subscription.close); + + expect(subscription.read(), const AsyncLoading>()); + }); + + test('accepts only valid pubkeys from a relay-signed archive snapshot', () { + final relay = nostr.Keys.generate(); + final archived = 'a' * 64; + final uppercase = 'B' * 64; + final event = nostr.Event.from( + kind: EventKind.archivedIdentities, + content: '', + tags: [ + ['-'], + ['p', archived], + ['p', uppercase], + ['p', 'not-a-pubkey'], + ], + secretKey: relay.secret, + verify: false, + ); + + expect( + archivedPubkeysFromSnapshot( + NostrEvent.fromJson(event.toMap()), + relay.public, + ), + {archived, uppercase.toLowerCase()}, + ); + }); + + test('rejects snapshots without exactly one valid NIP-70 tag', () { + final relay = nostr.Keys.generate(); + final archived = 'a' * 64; + + NostrEvent snapshotWith(List> tags) { + final event = nostr.Event.from( + kind: EventKind.archivedIdentities, + content: '', + tags: tags, + secretKey: relay.secret, + verify: false, + ); + return NostrEvent.fromJson(event.toMap()); + } + + expect( + archivedPubkeysFromSnapshot( + snapshotWith([ + ['p', archived], + ]), + relay.public, + ), + isEmpty, + ); + expect( + archivedPubkeysFromSnapshot( + snapshotWith([ + ['-', 'malformed'], + ['p', archived], + ]), + relay.public, + ), + isEmpty, + ); + expect( + archivedPubkeysFromSnapshot( + snapshotWith([ + ['-'], + ['-'], + ['p', archived], + ]), + relay.public, + ), + isEmpty, + ); + }); + + test('rejects a snapshot with a forged signature', () { + final relay = nostr.Keys.generate(); + final attacker = nostr.Keys.generate(); + final signed = nostr.Event.from( + kind: EventKind.archivedIdentities, + content: '', + tags: [ + ['-'], + ['p', 'a' * 64], + ], + secretKey: attacker.secret, + verify: false, + ); + final forged = NostrEvent.fromJson({ + ...signed.toMap(), + 'pubkey': relay.public, + }); + + expect(archivedPubkeysFromSnapshot(forged, relay.public), isEmpty); + }); + + test( + 'loads the active relay archive snapshot using its NIP-11 identity', + () async { + final relay = nostr.Keys.generate(); + final archived = 'c' * 64; + final signed = nostr.Event.from( + kind: EventKind.archivedIdentities, + content: '', + tags: [ + ['-'], + ['p', archived], + ], + secretKey: relay.secret, + verify: false, + ); + final relaySession = _ArchiveRelaySession([ + NostrEvent.fromJson(signed.toMap()), + ]); + late http.Request nip11Request; + final container = ProviderContainer( + overrides: [ + archivedIdentitiesHttpClientProvider.overrideWithValue( + http_testing.MockClient((request) async { + nip11Request = request; + return http.Response('{"self":"${relay.public}"}', 200); + }), + ), + relayConfigProvider.overrideWith( + () => _FakeRelayConfigNotifier('wss://relay.example.com'), + ), + relaySessionProvider.overrideWith(() => relaySession), + ], + ); + addTearDown(container.dispose); + + final result = await container.read( + archivedIdentityPubkeysProvider.future, + ); + + expect(nip11Request.url, Uri.parse('https://relay.example.com')); + expect(nip11Request.headers['Accept'], 'application/nostr+json'); + expect(relaySession.filters.single.toJson(), { + 'kinds': [EventKind.archivedIdentities], + 'limit': 1, + 'authors': [relay.public], + }); + expect(result, {archived}); + }, + ); +} + +class _ArchiveRelaySession extends RelaySessionNotifier { + final List events; + final List filters = []; + + _ArchiveRelaySession(this.events); + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) async { + this.filters.addAll(filters); + return events; + } +} + +class _DisconnectedRelaySession extends RelaySessionNotifier { + @override + SessionState build() => + const SessionState(status: SessionStatus.disconnected); +} + +class _FakeRelayConfigNotifier extends RelayConfigNotifier { + final String baseUrl; + + _FakeRelayConfigNotifier(this.baseUrl); + + @override + RelayConfig build() => RelayConfig(baseUrl: baseUrl); +}