diff --git a/mobile/lib/shared/me/me_assistants.dart b/mobile/lib/shared/me/me_assistants.dart index c7a67205f4..08bde19ef4 100644 --- a/mobile/lib/shared/me/me_assistants.dart +++ b/mobile/lib/shared/me/me_assistants.dart @@ -101,7 +101,10 @@ Future> _loadAssistants( sync.pubkey, )); sync.check(); - return agents; + if (agents.isEmpty) return agents; + final archived = await sync.archives.read(); + sync.check(); + return agents.where((agent) => !archived.contains(agent.pubkey)).toList(); } List _verifyProfiles(List events) { diff --git a/mobile/lib/shared/me/me_sync.dart b/mobile/lib/shared/me/me_sync.dart index 00fc1c7bed..fbaf21725b 100644 --- a/mobile/lib/shared/me/me_sync.dart +++ b/mobile/lib/shared/me/me_sync.dart @@ -9,6 +9,7 @@ import '../theme/theme_provider.dart'; import '../crypto/nip44.dart'; import '../relay/relay.dart'; +import '../relay/identity_archives.dart'; import 'me_groups.dart'; import 'me_recovery.dart'; import 'me_journal.dart'; @@ -192,6 +193,11 @@ final Provider meSyncProvider = Provider((ref) { class MeSync { final RelaySessionNotifier session; final RelayConfig config; + + /// Session-scoped authority and ordering checks for archived identities. + late final IdentityArchives archives = + _archives ?? IdentityArchives(config.baseUrl, session); + final IdentityArchives? _archives; late final String community = Uri.parse(config.baseUrl).origin; late final String privateHex = nostr.Nip19.decode(payload: config.nsec!).data; late final String pubkey = nostr.Keys(privateHex).public; @@ -214,7 +220,9 @@ class MeSync { bool connected = true, this.connectionState, this.prefs, - }) : _connected = connected; + IdentityArchives? archives, + }) : _archives = archives, + _connected = connected; /// Encrypted last verified organization, isolated by account and community. String get cacheKey => 'me-cache:$community:$pubkey'; diff --git a/mobile/lib/shared/relay/identity_archives.dart b/mobile/lib/shared/relay/identity_archives.dart new file mode 100644 index 0000000000..7240471139 --- /dev/null +++ b/mobile/lib/shared/relay/identity_archives.dart @@ -0,0 +1,116 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:nostr/nostr.dart' as nostr; + +import 'nostr_models.dart'; +import 'relay_session.dart'; + +/// Fresh relay-authoritative NIP-IA snapshots for forward-looking choices. +/// Keeps only an ordering fence, scoped to one account/community session. +class IdentityArchives { + IdentityArchives(this.baseUrl, this.session, {http.Client Function()? client}) + : _client = client ?? http.Client.new; + + final String baseUrl; + final RelaySessionNotifier session; + final http.Client Function() _client; + NostrEvent? _head; + + /// Fetches and verifies a fresh snapshot. Unknown state fails visibly. + Future> read() async { + final client = _client(); + try { + final base = Uri.parse(baseUrl); + final uri = Uri( + scheme: switch (base.scheme) { + 'wss' => 'https', + 'ws' => 'http', + _ => base.scheme, + }, + host: base.host, + port: base.hasPort ? base.port : null, + path: '/', + ); + final response = await client + .send( + http.Request('GET', uri) + ..headers['Accept'] = 'application/nostr+json', + ) + .timeout(const Duration(seconds: 5)); + if (response.statusCode != 200) { + throw StateError('Could not read relay identity.'); + } + final bytes = []; + await (() async { + await for (final chunk in response.stream) { + if (bytes.length + chunk.length > 256 * 1024) { + throw StateError('Relay identity document is too large.'); + } + bytes.addAll(chunk); + } + })().timeout(const Duration(seconds: 5)); + final info = jsonDecode(utf8.decode(bytes)); + final authority = info is Map ? info['self'] : null; + if (authority is! String || + !RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(authority)) { + throw StateError('Relay identity document has no valid self key.'); + } + final events = await session.queryRelay([ + NostrFilter( + kinds: const [EventKind.archivedIdentities], + authors: [authority.toLowerCase()], + limit: 1, + ), + ]); + if (events.isEmpty) { + if (_head != null) { + throw StateError('Relay archive snapshot disappeared.'); + } + return {}; + } + if (events.length != 1) { + throw StateError('Unexpected archive snapshot count.'); + } + final event = events.single; + final archived = await compute(_verifySnapshot, ( + event, + authority.toLowerCase(), + )); + final previous = _head; + if (previous != null && + (event.createdAt < previous.createdAt || + (event.createdAt == previous.createdAt && + event.id.compareTo(previous.id) > 0))) { + throw StateError('Relay returned an older archive snapshot.'); + } + _head = event; + return archived; + } finally { + client.close(); + } + } +} + +Set _verifySnapshot((NostrEvent, String) input) { + final (event, authority) = input; + final encoded = jsonEncode(event.toJson()); + final markers = event.tags + .where((tag) => tag.isNotEmpty && tag.first == '-') + .toList(); + if (event.kind != EventKind.archivedIdentities || + event.pubkey != authority || + event.content.isNotEmpty || + markers.length != 1 || + markers.single.length != 1 || + utf8.encode(encoded).length > 2 * 1024 * 1024) { + throw StateError('Invalid relay archive snapshot.'); + } + nostr.Event.fromJson(encoded); + final hex = RegExp(r'^[0-9a-f]{64}$'); + return { + for (final tag in event.tags) + if (tag.length >= 2 && tag.first == 'p' && hex.hasMatch(tag[1])) tag[1], + }; +} diff --git a/mobile/lib/shared/relay/nostr_models.dart b/mobile/lib/shared/relay/nostr_models.dart index 83ecda9a36..dcf76a067d 100644 --- a/mobile/lib/shared/relay/nostr_models.dart +++ b/mobile/lib/shared/relay/nostr_models.dart @@ -20,6 +20,9 @@ abstract final class EventKind { /// Kind:13534 event containing the current relay-community membership. static const relayMembership = 13534; + + /// NIP-IA relay-signed archived identity snapshot. + static const archivedIdentities = 13535; static const streamMessage = 9; static const nip29DeleteEvent = 9005; static const presenceUpdate = 20001; diff --git a/mobile/test/features/channels/me/solo_creation_tags_test.dart b/mobile/test/features/channels/me/solo_creation_tags_test.dart index 2108ce261a..0c592dbd61 100644 --- a/mobile/test/features/channels/me/solo_creation_tags_test.dart +++ b/mobile/test/features/channels/me/solo_creation_tags_test.dart @@ -1,4 +1,7 @@ import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:buzz/shared/relay/identity_archives.dart'; import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/channel_management_provider.dart'; @@ -32,7 +35,9 @@ class _Relay extends RelaySessionNotifier { Future> queryRelay( List filters, { Duration timeout = const Duration(seconds: 8), - }) async => [profile]; + }) async => filters.single.kinds.single == EventKind.archivedIdentities + ? [] + : [profile]; @override Future> fetchHistory( @@ -87,7 +92,18 @@ void main() { baseUrl: 'https://relay.test', nsec: owner.nsec, ); - final sync = MeSync(relay, config, prefs: prefs); + final sync = MeSync( + relay, + config, + prefs: prefs, + archives: IdentityArchives( + config.baseUrl, + relay, + client: () => MockClient( + (_) async => http.Response(jsonEncode({'self': owner.public}), 200), + ), + ), + ); final nativeChannels = []; const platformViews = MethodChannel('flutter/platform_views'); tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( diff --git a/mobile/test/shared/me/me_assistants_test.dart b/mobile/test/shared/me/me_assistants_test.dart index f0acd4097a..70664f50e2 100644 --- a/mobile/test/shared/me/me_assistants_test.dart +++ b/mobile/test/shared/me/me_assistants_test.dart @@ -1,4 +1,7 @@ import 'dart:async'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:buzz/shared/relay/identity_archives.dart'; import 'dart:convert'; import 'dart:typed_data'; import 'package:buzz/shared/me/me_assistants.dart'; @@ -14,6 +17,7 @@ class _Relay extends RelaySessionNotifier { final requestedPages = []; final requestedTags = >[]; final requestedAuthors = ?>[]; + List archives = []; _Relay(this.pages); @override SessionState build() => const SessionState(status: SessionStatus.connected); @@ -22,6 +26,9 @@ class _Relay extends RelaySessionNotifier { List filters, { Duration timeout = const Duration(seconds: 8), }) async { + if (filters.single.kinds.single == EventKind.archivedIdentities) { + return archives; + } expect(filters.single.kinds, [0]); requestedTags.add(filters.single.toJson()); requestedAuthors.add(filters.single.authors); @@ -72,6 +79,13 @@ void main() { ProviderContainer setup(_Relay relay) { final sync = MeSync( relay, + archives: IdentityArchives( + 'https://relay.test', + relay, + client: () => MockClient( + (_) async => http.Response(jsonEncode({'self': other.public}), 200), + ), + ), RelayConfig( baseUrl: 'https://relay.test', nsec: nostr.Nip19.encode( @@ -90,6 +104,33 @@ void main() { return container; } + test('fresh directory and creation exclude archived owned agents', () async { + final relay = _Relay([ + [profile(agent, ownedBy: owner)], + ]); + final container = setup(relay); + expect(await container.read(meAssistantsProvider.future), hasLength(1)); + relay.archives = [ + NostrEvent.fromJson( + nostr.Event.from( + kind: EventKind.archivedIdentities, + content: '', + secretKey: other.secret, + tags: [ + ['-'], + ['p', agent.public], + ], + ).toMap(), + ), + ]; + container.invalidate(meAssistantsProvider); + expect(await container.read(meAssistantsProvider.future), isEmpty); + expect( + await isOwnedMeAssistant(container.read(meSyncProvider), agent.public), + isFalse, + ); + }); + test('directory verification keeps the event loop responsive', () async { final agents = [for (var i = 0; i < 99; i++) nostr.Keys.generate()]; final events = [for (final key in agents) profile(key, ownedBy: owner)]; diff --git a/mobile/test/shared/relay/identity_archives_test.dart b/mobile/test/shared/relay/identity_archives_test.dart new file mode 100644 index 0000000000..b0f4578e4c --- /dev/null +++ b/mobile/test/shared/relay/identity_archives_test.dart @@ -0,0 +1,128 @@ +import 'dart:convert'; + +import 'package:buzz/shared/relay/identity_archives.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:nostr/nostr.dart' as nostr; + +class _Relay extends RelaySessionNotifier { + List events = []; + final String authority; + _Relay(this.authority); + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) async { + expect(filters.single.kinds, [EventKind.archivedIdentities]); + expect(filters.single.authors, [authority]); + expect(filters.single.limit, 1); + return events; + } +} + +void main() { + final authority = nostr.Keys.generate(); + final agent = nostr.Keys.generate().public; + NostrEvent snapshot({ + int time = 10, + bool archived = true, + List>? tags, + nostr.Keys? signer, + }) => NostrEvent.fromJson( + nostr.Event.from( + kind: EventKind.archivedIdentities, + content: '', + secretKey: (signer ?? authority).secret, + createdAt: time, + tags: + tags ?? + [ + ['-'], + if (archived) ['p', agent], + ], + ).toMap(), + ); + IdentityArchives reader(_Relay relay, {Map? document}) => + IdentityArchives( + 'https://relay.test', + relay, + client: () => MockClient((request) async { + expect(request.url.toString(), 'https://relay.test/'); + expect(request.headers['Accept'], 'application/nostr+json'); + return http.Response( + jsonEncode(document ?? {'self': authority.public.toUpperCase()}), + 200, + ); + }), + ); + + test( + 'fresh archive then unarchive replaces snapshot without sticky exclusions', + () async { + final relay = _Relay(authority.public); + final archives = reader(relay); + expect(await archives.read(), isEmpty); + relay.events = [snapshot()]; + expect(await archives.read(), {agent}); + relay.events = [snapshot(time: 11, archived: false)]; + expect(await archives.read(), isEmpty); + relay.events = [snapshot()]; + await expectLater(archives.read(), throwsStateError); + }, + ); + test('does not substitute contact pubkey for relay self', () async { + final relay = _Relay(authority.public); + await expectLater( + reader(relay, document: {'pubkey': authority.public}).read(), + throwsStateError, + ); + }); + test( + 'rejects forged signatures, wrong authors and malformed protection', + () async { + final relay = _Relay(authority.public); + final badSignature = snapshot().toJson()..['sig'] = '0' * 128; + for (final event in [ + NostrEvent.fromJson(badSignature), + snapshot(signer: nostr.Keys.generate()), + snapshot( + tags: [ + ['p', agent], + ], + ), + snapshot( + tags: [ + ['-', 'bad'], + ['p', agent], + ], + ), + snapshot( + tags: [ + ['-'], + ['-'], + ['p', agent], + ], + ), + ]) { + relay.events = [event]; + await expectLater( + reader(relay).read(), + throwsA(anyOf(isA(), isA())), + ); + } + }, + ); + test( + 'does not silently erase known archive state on missing response', + () async { + final relay = _Relay(authority.public)..events = [snapshot()]; + final archives = reader(relay); + expect(await archives.read(), {agent}); + relay.events = []; + await expectLater(archives.read(), throwsStateError); + }, + ); +}