diff --git a/mobile/lib/shared/me/me_assistants.dart b/mobile/lib/shared/me/me_assistants.dart index 65830d63b6..8e5d612926 100644 --- a/mobile/lib/shared/me/me_assistants.dart +++ b/mobile/lib/shared/me/me_assistants.dart @@ -55,9 +55,7 @@ Future> _loadAssistants( RelaySessionNotifier session, { String? pubkey, }) async { - final candidates = pubkey == null - ? await _managedAgentPubkeys(sync, session) - : [pubkey]; + final candidates = await _managedAgentPubkeys(sync, session, agent: pubkey); final profiles = {}; for (var offset = 0; offset < candidates.length; offset += _pageSize) { final batch = candidates.sublist( @@ -98,12 +96,16 @@ Future> _loadAssistants( /// Candidate agents from the owner's own managed-agent records, as desktop /// discovers them. The records are discovery hints only: each candidate's -/// profile must still carry a valid NIP-OA attestation by the owner. +/// profile must still carry a valid NIP-OA attestation by the owner. With +/// [agent], only that agent's record is read, so an agent removed from the +/// inventory also fails revalidation before chat creation and send. Future> _managedAgentPubkeys( MeSync sync, - RelaySessionNotifier session, -) async { + RelaySessionNotifier session, { + String? agent, +}) async { final heads = {}; + final verified = {}; NostrEvent? cursor; for (var page = 0; ; page++) { if (page == _maxInventoryPages) { @@ -116,6 +118,9 @@ Future> _managedAgentPubkeys( authors: [sync.pubkey], limit: _pageSize, until: cursor?.createdAt, + tags: { + if (agent != null) '#d': [agent], + }, extensions: {'before_id': ?cursor?.id}, ), ]); @@ -130,10 +135,21 @@ Future> _managedAgentPubkeys( throw StateError('Agent inventory response is outside its owner.'); } for (final event in events) { - final agent = event.getTagValue('d'); - if (agent == null) continue; - final previous = heads[agent]; - if (previous == null || _newer(event, previous)) heads[agent] = event; + final d = event.getTagValue('d'); + if (d == null || (agent != null && d != agent)) continue; + // Like desktop, skip an envelope that fails verification so a forged + // record can neither add an agent nor supersede a genuine one. An ID + // already verified needs no second check when pages overlap. + if (!verified.contains(event.id)) { + try { + nostr.Event.fromJson(jsonEncode(event.toJson())); + } catch (_) { + continue; + } + verified.add(event.id); + } + final previous = heads[d]; + if (previous == null || _newer(event, previous)) heads[d] = event; } if (events.length < _pageSize) break; // Relay order is newest first, then ID ascending at ties. @@ -144,8 +160,8 @@ Future> _managedAgentPubkeys( cursor = last; } return [ - for (final MapEntry(key: agent, value: event) in heads.entries) - if (_agentPubkey.hasMatch(agent) && _hasName(event)) agent, + for (final MapEntry(key: d, value: event) in heads.entries) + if (_agentPubkey.hasMatch(d) && _hasName(event)) d, ]; } 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 009f5eba35..80b111398b 100644 --- a/mobile/test/features/channels/me/solo_creation_tags_test.dart +++ b/mobile/test/features/channels/me/solo_creation_tags_test.dart @@ -47,15 +47,18 @@ class _EmptyChannels extends ChannelsNotifier { class _Relay extends RelaySessionNotifier { final NostrEvent profile; + final NostrEvent inventory; final submitted = []; - _Relay(this.profile); + _Relay(this.profile, this.inventory); @override Future> queryRelay( List filters, { Duration timeout = const Duration(seconds: 8), - }) async => - filters.any((filter) => filter.kinds.contains(0)) ? [profile] : []; + }) async => [ + if (filters.any((filter) => filter.kinds.contains(0))) profile, + if (filters.any((filter) => filter.kinds.contains(30177))) inventory, + ]; @override Future> fetchHistory( @@ -108,7 +111,17 @@ void main() { ], ).toMap(), ); - final relay = _Relay(profile); + final inventory = NostrEvent.fromJson( + nostr.Event.from( + kind: 30177, + content: jsonEncode({'name': 'Owned agent'}), + secretKey: owner.secret, + tags: [ + ['d', agent.public], + ], + ).toMap(), + ); + final relay = _Relay(profile, inventory); final config = RelayConfig( baseUrl: 'https://relay.test', nsec: owner.nsec, diff --git a/mobile/test/shared/me/me_assistants_test.dart b/mobile/test/shared/me/me_assistants_test.dart index 465e3991d0..bbdde6ed87 100644 --- a/mobile/test/shared/me/me_assistants_test.dart +++ b/mobile/test/shared/me/me_assistants_test.dart @@ -29,6 +29,11 @@ class _Relay extends RelaySessionNotifier { final filter = filters.single.toJson(); if (filters.single.kinds.single == 30177) { inventoryFilters.add(filter); + final d = (filter['#d'] as List?)?.cast(); + if (d != null) { + // Return every record so the client must enforce the requested d. + return [for (final page in inventory) ...page]; + } final page = inventoryFilters.length - 1; return page < inventory.length ? inventory[page] : const []; } @@ -79,28 +84,30 @@ void main() { var fakeAgents = 0; String fakeAgent() => (++fakeAgents).toRadixString(16).padLeft(64, 'a'); - // Discovery does not verify inventory signatures (the relay does), so the - // records skip Schnorr signing to keep large pagination fixtures fast. - var records = 0; - final ownerPubkey = owner.public; NostrEvent managed( String d, { nostr.Keys? by, int time = 1, int kind = 30177, Object? content = const {'name': 'Agent'}, - }) => NostrEvent( - id: (++records).toRadixString(16).padLeft(64, '0'), - pubkey: by?.public ?? ownerPubkey, - createdAt: time, - kind: kind, - tags: [ - ['d', d], - ], - content: jsonEncode(content), - sig: '', + }) => NostrEvent.fromJson( + nostr.Event.from( + kind: kind, + content: jsonEncode(content), + secretKey: (by ?? owner).secret, + createdAt: time, + tags: [ + ['d', d], + ], + ).toMap(), ); + // A full page of signed records with distinct times, shared because + // signing dominates fixture cost. + late final fullPage = [ + for (var i = 0; i < 100; i++) managed(fakeAgent(), time: 5000 - i), + ]; + ProviderContainer setup(_Relay relay) { final sync = MeSync( relay, @@ -245,9 +252,7 @@ void main() { expect(relay.profileAuthors.single, [agent.public]); }); test('reads beyond the first inventory page with a cursor', () async { - final first = [ - for (var i = 0; i < 100; i++) managed(fakeAgent(), time: 200 - i), - ]; + final first = fullPage; final relay = _Relay( inventory: [ first, @@ -260,7 +265,7 @@ void main() { agent.public, ); expect(relay.inventoryFilters.length, 2); - expect(relay.inventoryFilters.last['until'], 101); + expect(relay.inventoryFilters.last['until'], 4901); expect(relay.inventoryFilters.last['before_id'], first.last.id); expect(relay.profileAuthors.map((a) => a!.length), [100, 1]); expect(relay.profileFilters.map((f) => f['limit']), [100, 1]); @@ -270,11 +275,9 @@ void main() { () async { final relay = _Relay( inventory: [ + // Each page ends at an older record, so the cursor keeps advancing. for (var page = 0; page < 11; page++) - [ - for (var i = 0; i < 100; i++) - managed(fakeAgent(), time: 2000 - page * 100 - i), - ], + [...fullPage.take(99), managed(fakeAgent(), time: 100 - page)], ], ); await expectLater( @@ -286,8 +289,7 @@ void main() { }, ); test('inventory paging fails when the cursor does not advance', () async { - final page = [for (var i = 0; i < 100; i++) managed(fakeAgent())]; - final relay = _Relay(inventory: [page, page]); + final relay = _Relay(inventory: [fullPage, fullPage]); await expectLater( setup(relay).read(meAssistantsProvider.future), throwsStateError, @@ -299,9 +301,12 @@ void main() { // Relay order is created_at descending, then id ascending. An unordered // tied page must still advance from its largest id. final tied = [for (var i = 0; i < 100; i++) managed(fakeAgent(), time: 5)]; + final largest = tied + .map((e) => e.id) + .reduce((a, b) => a.compareTo(b) > 0 ? a : b); final relay = _Relay( inventory: [ - [...tied.reversed], + [...tied]..sort((a, b) => b.id.compareTo(a.id)), [managed(agent.public, time: 5)], ], profiles: [profile(agent, ownedBy: owner)], @@ -311,27 +316,29 @@ void main() { agent.public, ); expect(relay.inventoryFilters.last['until'], 5); - expect(relay.inventoryFilters.last['before_id'], tied.last.id); + expect(relay.inventoryFilters.last['before_id'], largest); }); test('same-second inventory records keep the lowest id', () async { + final named = managed(agent.public, time: 5); + final unnamed = managed(agent.public, time: 5, content: {'name': ''}); final relay = _Relay( inventory: [ - [ - managed(agent.public, time: 5), - managed(agent.public, time: 5, content: {'name': ''}), - ], + [named, unnamed], ], profiles: [profile(agent, ownedBy: owner)], ); + final namedWins = named.id.compareTo(unnamed.id) < 0; expect( - (await setup(relay).read(meAssistantsProvider.future)).single.pubkey, - agent.public, + (await setup( + relay, + ).read(meAssistantsProvider.future)).map((a) => a.pubkey), + namedWins ? [agent.public] : isEmpty, ); }); test('rejects an inventory page larger than requested', () async { final relay = _Relay( inventory: [ - [for (var i = 0; i < 101; i++) managed(fakeAgent())], + [...fullPage, managed(fakeAgent())], ], ); await expectLater( @@ -381,21 +388,77 @@ void main() { ); } test('creation revalidates only the chosen assistant', () async { - final relay = _Relay(profiles: [profile(agent, ownedBy: owner)]); + final relay = _Relay( + inventory: [ + [managed(other.public), managed(agent.public)], + ], + profiles: [ + profile(agent, ownedBy: owner), + profile(other, ownedBy: owner), + ], + ); final container = setup(relay); expect( await isOwnedMeAssistant(container.read(meSyncProvider), agent.public), isTrue, ); - expect(relay.inventoryFilters, isEmpty); + expect(relay.inventoryFilters.single['authors'], [owner.public]); + expect(relay.inventoryFilters.single['#d'], [agent.public]); expect(relay.profileAuthors, [ [agent.public], ]); }); + test('creation rejects an agent removed from the inventory', () async { + final relay = _Relay( + inventory: [ + [managed(other.public)], + ], + profiles: [profile(agent, ownedBy: owner)], + ); + expect( + await isOwnedMeAssistant(setup(relay).read(meSyncProvider), agent.public), + isFalse, + ); + expect(relay.profileFilters, isEmpty); + }); + test('ignores inventory records that fail verification', () async { + final genuine = managed(other.public, time: 1); + final forged = managed(agent.public, time: 2).toJson() + ..['sig'] = genuine.sig; + final superseding = + managed(other.public, time: 3, content: {'name': ''}).toJson() + ..['content'] = jsonEncode({'name': ''}) + ..['sig'] = genuine.sig; + final relay = _Relay( + inventory: [ + [ + NostrEvent.fromJson(superseding), + NostrEvent.fromJson(forged), + genuine, + ], + ], + profiles: [ + profile(agent, ownedBy: owner), + profile(other, ownedBy: owner), + ], + ); + final container = setup(relay); + expect( + (await container.read(meAssistantsProvider.future)).map((a) => a.pubkey), + [other.public], + ); + expect( + await isOwnedMeAssistant(container.read(meSyncProvider), agent.public), + isFalse, + ); + }); test( 'creation rejects revoked ownership and unrelated returned profiles', () async { final relay = _Relay( + inventory: [ + [managed(agent.public), managed(other.public)], + ], profiles: [ profile(agent, ownedBy: owner), profile(agent, time: 2),