diff --git a/mobile/lib/shared/me/me_assistants.dart b/mobile/lib/shared/me/me_assistants.dart index 8e5d612926..ad36a994cf 100644 --- a/mobile/lib/shared/me/me_assistants.dart +++ b/mobile/lib/shared/me/me_assistants.dart @@ -105,7 +105,7 @@ Future> _managedAgentPubkeys( String? agent, }) async { final heads = {}; - final verified = {}; + final verified = {}; NostrEvent? cursor; for (var page = 0; ; page++) { if (page == _maxInventoryPages) { @@ -135,21 +135,23 @@ Future> _managedAgentPubkeys( throw StateError('Agent inventory response is outside its owner.'); } for (final event in events) { - 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)) { + // record can neither add an agent nor supersede a genuine one. When + // pages overlap, a repeated ID uses only the envelope already verified + // for it, never the bytes the relay sent again. + var record = verified[event.id]; + if (record == null) { try { nostr.Event.fromJson(jsonEncode(event.toJson())); } catch (_) { continue; } - verified.add(event.id); + record = verified[event.id] = event; } + final d = record.getTagValue('d'); + if (d == null || (agent != null && d != agent)) continue; final previous = heads[d]; - if (previous == null || _newer(event, previous)) heads[d] = event; + if (previous == null || _newer(record, previous)) heads[d] = record; } if (events.length < _pageSize) break; // Relay order is newest first, then ID ascending at ties. diff --git a/mobile/test/shared/me/me_assistants_test.dart b/mobile/test/shared/me/me_assistants_test.dart index bbdde6ed87..652e1f18fd 100644 --- a/mobile/test/shared/me/me_assistants_test.dart +++ b/mobile/test/shared/me/me_assistants_test.dart @@ -452,6 +452,39 @@ void main() { isFalse, ); }); + for (final forgedFirst in [false, true]) { + test( + 'a repeated ID cannot inject an agent (forged first: $forgedFirst)', + () async { + final genuine = managed(agent.public); + final forged = NostrEvent.fromJson( + genuine.toJson() + ..['tags'] = [ + ['d', other.public], + ], + ); + final relay = _Relay( + inventory: [ + forgedFirst ? [forged, genuine] : [genuine, forged], + ], + profiles: [ + profile(agent, ownedBy: owner), + profile(other, ownedBy: owner), + ], + ); + final container = setup(relay); + expect( + (await container.read( + meAssistantsProvider.future, + )).map((a) => a.pubkey), + [agent.public], + ); + final sync = container.read(meSyncProvider); + expect(await isOwnedMeAssistant(sync, other.public), isFalse); + expect(await isOwnedMeAssistant(sync, agent.public), isTrue); + }, + ); + } test( 'creation rejects revoked ownership and unrelated returned profiles', () async {