diff --git a/mobile/lib/features/channels/channel_management_provider.dart b/mobile/lib/features/channels/channel_management_provider.dart index 6fad4ecc4a4..199c6519e8d 100644 --- a/mobile/lib/features/channels/channel_management_provider.dart +++ b/mobile/lib/features/channels/channel_management_provider.dart @@ -296,17 +296,7 @@ List relayMemberPubkeysFromEvents(List events) { /// Converts kind:0 events into a deduplicated, alphabetized people directory. @visibleForTesting List directoryUsersFromProfileEvents(List events) { - final latestByPubkey = {}; - for (final event in events) { - if (event.kind != 0) { - continue; - } - final pubkey = event.pubkey.toLowerCase(); - final current = latestByPubkey[pubkey]; - if (current == null || event.createdAt > current.createdAt) { - latestByPubkey[pubkey] = event; - } - } + final latestByPubkey = latestProfileEvents(events); return [ for (final event in latestByPubkey.values) @@ -316,7 +306,7 @@ List directoryUsersFromProfileEvents(List events) { displayName: profile.displayName, avatarUrl: profile.avatarUrl, nip05Handle: profile.nip05, - isAgent: verifiedOaOwnerPubkey(event.tags, event.pubkey) != null, + isAgent: verifiedOaOwnerPubkey(event) != null, ), ]..sort((a, b) { final labelComparison = a.label.toLowerCase().compareTo( @@ -373,29 +363,14 @@ final relayDirectoryUsersProvider = NostrFilters.profilesBatch(memberPubkeys), ]); final profilesByPubkey = { - for (final event in profileEvents) - event.pubkey.toLowerCase(): ProfileData.fromEvent(event), + for (final user in directoryUsersFromProfileEvents(profileEvents)) + user.pubkey: user, }; users = [ for (final pubkey in memberPubkeys) if (profilesByPubkey[pubkey] case final profile?) - DirectoryUser( - pubkey: pubkey, - displayName: profile.displayName, - avatarUrl: profile.avatarUrl, - nip05Handle: profile.nip05, - isAgent: - verifiedOaOwnerPubkey( - profileEvents - .firstWhere( - (event) => event.pubkey.toLowerCase() == pubkey, - ) - .tags, - pubkey, - ) != - null, - ) + profile else DirectoryUser(pubkey: pubkey), ]..sort((a, b) { diff --git a/mobile/lib/features/channels/mentions/mention_candidates.dart b/mobile/lib/features/channels/mentions/mention_candidates.dart index 6f3bae57a27..930ccef2b35 100644 --- a/mobile/lib/features/channels/mentions/mention_candidates.dart +++ b/mobile/lib/features/channels/mentions/mention_candidates.dart @@ -12,6 +12,11 @@ bool agentIsSharedWithUser( Set sharedChannelIds, String? currentPubkey, ) { + if (currentPubkey != null && + agent.ownerPubkey == currentPubkey.toLowerCase() && + const ['owner-only', 'allowlist', 'anyone'].contains(agent.respondTo)) { + return true; + } if (agent.respondTo == 'allowlist' && currentPubkey != null) { return agent.respondToAllowlist.contains(currentPubkey.toLowerCase()); } @@ -62,6 +67,11 @@ List buildMentionCandidates({ final profile = userCache[pk]; final ownerPubkey = ownerByAgentPubkey[pk] ?? profile?.ownerPubkey; final isAgent = member.isBot || ownerPubkey != null; + final policy = relayAgents.where((agent) => agent.pubkey == pk).firstOrNull; + if (policy?.ownerPubkey != null && + !agentIsSharedWithUser(policy!, sharedChannelIds, currentPubkey)) { + continue; + } candidates.add( MentionCandidate( pubkey: pk, @@ -121,6 +131,12 @@ List buildMentionCandidates({ // verified NIP-OA owner) or shared via the relay agent directory. final ownedByCurrentUser = currentLower != null && ownerPubkey?.toLowerCase() == currentLower; + final policy = relayAgents + .where((agent) => agent.pubkey == pk) + .firstOrNull; + if (policy?.ownerPubkey != null && !sharedAgentPubkeys.contains(pk)) { + continue; + } if (!ownedByCurrentUser && !sharedAgentPubkeys.contains(pk)) { continue; } diff --git a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart index 85930a45995..6e225b45017 100644 --- a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart +++ b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart @@ -38,16 +38,7 @@ final mentionUserSearchProvider = FutureProvider.autoDispose // Keep only the latest kind:0 event per pubkey (the bridge does not // honor the `kinds` filter under search, and may return several // profile revisions — mirrors desktop's `list_user_search_results`). - final latestByPubkey = {}; - for (final event in events) { - if (event.kind != 0) continue; - final pk = event.pubkey.toLowerCase(); - final current = latestByPubkey[pk]; - if (current == null || event.createdAt > current.createdAt) { - latestByPubkey[pk] = event; - } - } - + final latestByPubkey = latestProfileEvents(events); return [ for (final event in latestByPubkey.values) _profileFromEvent(event), ]; @@ -61,7 +52,7 @@ UserProfile _profileFromEvent(NostrEvent event) { avatarUrl: data.avatarUrl, about: data.about, nip05Handle: data.nip05, - ownerPubkey: verifiedOaOwnerPubkey(event.tags, event.pubkey), + ownerPubkey: verifiedOaOwnerPubkey(event), ); } diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index 93621f7608a..a1a283fb499 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -116,7 +116,7 @@ class ProfileNotifier extends AsyncNotifier { avatarUrl: data.avatarUrl, about: data.about, nip05Handle: data.nip05, - ownerPubkey: verifiedOaOwnerPubkey(latest.tags, data.pubkey), + ownerPubkey: verifiedOaOwnerPubkey(latest), ); _requireCurrentWriteContext(context); _metadata = metadata; @@ -233,7 +233,7 @@ class ProfileNotifier extends AsyncNotifier { avatarUrl: _metadata['picture'] as String?, about: _metadata['about'] as String?, nip05Handle: _metadata['nip05'] as String?, - ownerPubkey: verifiedOaOwnerPubkey(submittedEvent.tags, pubkey), + ownerPubkey: verifiedOaOwnerPubkey(submittedEvent), ); state = AsyncData(profile); ref.read(userCacheProvider.notifier).put(profile); diff --git a/mobile/lib/shared/crypto/nip_oa.dart b/mobile/lib/shared/crypto/nip_oa.dart index f9d00ef1c59..b2017be8e04 100644 --- a/mobile/lib/shared/crypto/nip_oa.dart +++ b/mobile/lib/shared/crypto/nip_oa.dart @@ -4,6 +4,9 @@ import 'dart:typed_data'; import 'package:nostr/nostr.dart' as nostr; import 'package:pointycastle/digests/sha256.dart'; +import '../relay/nostr_models.dart'; +import 'signed_event.dart'; + /// NIP-OA (Owner Attestation) — verify the `auth` tag on a kind:0 profile /// that proves an owner key authorized an agent key. /// @@ -15,48 +18,60 @@ import 'package:pointycastle/digests/sha256.dart'; /// verified against the profile event author, so a forged or stale marker /// cannot turn a person into an agent. /// -/// Returns the owner pubkey (lowercase hex) for the first valid auth tag, -/// or null if none verifies. -String? verifiedOaOwnerPubkey(List> tags, String agentPubkey) { - final agent = agentPubkey.toLowerCase(); - - for (final tag in tags) { - if (tag.length != 4 || tag[0] != 'auth') continue; - - final owner = tag[1].toLowerCase(); - final conditions = tag[2]; - final sig = tag[3]; - - // Self-attestation is meaningless and rejected. - if (owner == agent) continue; - if (owner.length != 64 || sig.length != 128) continue; - if (!_validConditions(conditions)) continue; - - final preimage = utf8.encode('nostr:agent-auth:$agent:$conditions'); - final digest = SHA256Digest().process(Uint8List.fromList(preimage)); - final message = digest - .map((b) => b.toRadixString(16).padLeft(2, '0')) - .join(); +/// Returns the owner only when the signed profile has exactly one valid auth +/// tag whose conditions apply to that event (not to the verifier's clock). +String? verifiedOaOwnerPubkey(NostrEvent event) { + if (event.kind != 0) return null; + final tags = event.tags.where((tag) => tag.isNotEmpty && tag[0] == 'auth'); + if (tags.length != 1) return null; + final tag = tags.single; + if (tag.length != 4) return null; + final owner = tag[1]; + final conditions = tag[2]; + final sig = tag[3]; + if (owner == event.pubkey || + !RegExp(r'^[0-9a-f]{64}$').hasMatch(owner) || + !RegExp(r'^[0-9a-f]{128}$').hasMatch(sig) || + !_validConditions(conditions, event) || + !verifySignedEvent(event)) { + return null; + } + final preimage = utf8.encode('nostr:agent-auth:${event.pubkey}:$conditions'); + final digest = SHA256Digest().process(Uint8List.fromList(preimage)); + final message = digest.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + try { + return nostr.Schnorr.verify( + publicKey: owner, + message: message, + signature: sig, + ) + ? owner + : null; + } catch (_) { + return null; + } +} - try { - if (nostr.Schnorr.verify( - publicKey: owner, - message: message, - signature: sig, - )) { - return owner; - } - } catch (_) { - // Malformed hex — treat as an invalid tag. +/// Select the latest profile before checking ownership, including revocations. +/// NIP-01 ties choose the lowest event id, independent of response order. +Map latestProfileEvents(Iterable events) { + final latest = {}; + for (final event in events.where((event) => event.kind == 0)) { + final key = event.pubkey.toLowerCase(); + final previous = latest[key]; + if (previous == null || + event.createdAt > previous.createdAt || + (event.createdAt == previous.createdAt && + event.id.compareTo(previous.id) < 0)) { + latest[key] = event; } } - - return null; + return latest; } /// Validate the NIP-OA `conditions` string: empty, or `&`-joined clauses of /// `kind=`, `created_at<`, or `created_at>` with canonical decimals. -bool _validConditions(String conditions) { +bool _validConditions(String conditions, NostrEvent event) { if (conditions.isEmpty) return true; if (conditions.contains(RegExp(r'\s'))) return false; @@ -67,7 +82,15 @@ bool _validConditions(String conditions) { if (match == null) return false; final value = int.tryParse(match.group(1)!); if (value == null || value > 4294967295) return false; - if (clause.startsWith('kind=') && value > 65535) return false; + if (clause.startsWith('kind=') && (value > 65535 || value != event.kind)) { + return false; + } + if (clause.startsWith('created_at<') && event.createdAt >= value) { + return false; + } + if (clause.startsWith('created_at>') && event.createdAt <= value) { + return false; + } } return true; diff --git a/mobile/lib/shared/crypto/signed_event.dart b/mobile/lib/shared/crypto/signed_event.dart new file mode 100644 index 00000000000..1b51094563f --- /dev/null +++ b/mobile/lib/shared/crypto/signed_event.dart @@ -0,0 +1,27 @@ +import 'package:nostr/nostr.dart' as nostr; + +import '../relay/nostr_models.dart'; + +/// Verify the canonical event id and author's signature without a wall-clock +/// freshness restriction. Authority readers apply their own kind/signer scope. +bool verifySignedEvent(NostrEvent event) { + if (!RegExp(r'^[0-9a-f]{64}$').hasMatch(event.pubkey) || + !RegExp(r'^[0-9a-f]{64}$').hasMatch(event.id) || + !RegExp(r'^[0-9a-f]{128}$').hasMatch(event.sig) || + event.createdAt < 0 || + event.kind < 0 || + event.kind > 65535) { + return false; + } + try { + final signed = nostr.Event.fromMap(event.toJson(), verify: false); + return signed.getEventId() == event.id && + nostr.Schnorr.verify( + publicKey: event.pubkey, + message: event.id, + signature: event.sig, + ); + } catch (_) { + return false; + } +} diff --git a/mobile/lib/shared/mentions/agent_identity_provider.dart b/mobile/lib/shared/mentions/agent_identity_provider.dart index 35c063d13d9..e5be060be40 100644 --- a/mobile/lib/shared/mentions/agent_identity_provider.dart +++ b/mobile/lib/shared/mentions/agent_identity_provider.dart @@ -5,8 +5,11 @@ import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/crypto/nip_oa.dart'; +import '../../shared/crypto/signed_event.dart'; import '../../shared/relay/relay.dart'; +part 'agent_policy.dart'; + /// A relay agent parsed from its kind:10100 agent-profile event. /// /// Mirrors the fields desktop's `RelayAgent` uses for mention eligibility @@ -15,6 +18,7 @@ import '../../shared/relay/relay.dart'; class AgentDirectoryEntry { final String pubkey; final String? displayName; + final String? ownerPubkey; final String? respondTo; final List respondToAllowlist; final List channelIds; @@ -22,6 +26,7 @@ class AgentDirectoryEntry { const AgentDirectoryEntry({ required this.pubkey, this.displayName, + this.ownerPubkey, this.respondTo, this.respondToAllowlist = const [], this.channelIds = const [], @@ -66,7 +71,7 @@ final agentDirectoryProvider = FutureProvider>(( if (sessionState.status != SessionStatus.connected) return const []; final session = ref.read(relaySessionProvider.notifier); final events = await session.fetchHistory(NostrFilters.agentProfiles()); - return [for (final event in events) AgentDirectoryEntry.fromEvent(event)]; + return resolveAgentPolicies(session, events); }); /// Verified NIP-OA owner pubkey per agent pubkey, from the agents' kind:0 @@ -80,8 +85,8 @@ final agentOwnersProvider = FutureProvider>((ref) async { NostrFilters.profilesBatch([for (final agent in agents) agent.pubkey]), ); final owners = {}; - for (final event in events) { - final owner = verifiedOaOwnerPubkey(event.tags, event.pubkey); + for (final event in latestProfileEvents(events).values) { + final owner = verifiedOaOwnerPubkey(event); if (owner != null) owners[event.pubkey.toLowerCase()] = owner; } return owners; diff --git a/mobile/lib/shared/mentions/agent_policy.dart b/mobile/lib/shared/mentions/agent_policy.dart new file mode 100644 index 00000000000..be7f6642b88 --- /dev/null +++ b/mobile/lib/shared/mentions/agent_policy.dart @@ -0,0 +1,140 @@ +part of 'agent_identity_provider.dart'; + +bool _newer(NostrEvent event, NostrEvent previous) => + event.createdAt > previous.createdAt || + (event.createdAt == previous.createdAt && + event.id.compareTo(previous.id) < 0); + +/// Exact-coordinate queries, bounded to ten filters in flight. An empty result +/// is distinct from a failed read; failures must never revive runtime access. +Future> _queryAgentFilters( + RelaySessionNotifier session, + List filters, +) async { + final events = []; + for (var start = 0; start < filters.length; start += 10) { + events.addAll( + await session.queryRelay(filters.skip(start).take(10).toList()), + ); + } + return events; +} + +/// Overlay current owner-authenticated policy onto the existing runtime +/// directory. This does not expand discovery to owner-only coordinates yet. +Future> resolveAgentPolicies( + RelaySessionNotifier session, + List runtimeEvents, +) async { + final latest = {}; + for (final event in runtimeEvents.where((event) => event.kind == 10100)) { + final previous = latest[event.pubkey]; + if (previous == null || _newer(event, previous)) { + latest[event.pubkey] = event; + } + } + final profiles = latestProfileEvents( + await _queryAgentFilters(session, [ + for (final key in latest.keys) + NostrFilter(kinds: const [0], authors: [key], limit: 1), + ]), + ); + final owners = {}; + for (final profile in profiles.values) { + final owner = verifiedOaOwnerPubkey(profile); + if (owner != null && latest.containsKey(profile.pubkey)) { + owners[profile.pubkey] = owner; + } + } + final policies = await _queryAgentFilters(session, [ + for (final owner in owners.entries) + NostrFilter( + kinds: const [30177], + authors: [owner.value], + tags: { + '#d': [owner.key], + }, + limit: 1, + ), + ]); + return mergeAgentPolicies(latest.values, policies, owners); +} + +/// Latest signed owner policy is the access authority, not runtime claims. +/// Malformed/revoked policy reserves the identity with deny-all permissions; +/// it must not fall back to an older permissive policy or kind:10100 record. +List mergeAgentPolicies( + Iterable runtimeEvents, + Iterable policies, + Map verifiedOwners, +) { + final latestPolicies = {}; + for (final event in policies) { + final key = event.getTagValue('d'); + if (key == null || verifiedOwners[key] != event.pubkey) continue; + final previous = latestPolicies[key]; + if (previous == null || _newer(event, previous)) { + latestPolicies[key] = event; + } + } + final agents = {}; + for (final event in runtimeEvents) { + if (event.kind != 10100 || !verifySignedEvent(event)) continue; + final data = _tryDecodeJsonMap(event.content); + if (data == null) continue; + agents[event.pubkey] = AgentDirectoryEntry( + pubkey: event.pubkey, + displayName: data['display_name'] is String + ? data['display_name'] as String + : data['name'] is String + ? data['name'] as String + : null, + respondTo: data['respond_to'] is String + ? data['respond_to'] as String + : null, + respondToAllowlist: _stringList(data['respond_to_allowlist']) ?? const [], + channelIds: _stringList(data['channel_ids']) ?? const [], + ); + } + for (final entry in latestPolicies.entries) { + final event = entry.value; + final data = _tryDecodeJsonMap(event.content); + final mode = data?['respond_to']; + final parallelism = data?['parallelism']; + final allowlist = _stringList(data?['respond_to_allowlist'] ?? []); + final valid = + event.kind == 30177 && + verifySignedEvent(event) && + event.tags.where((tag) => tag.isNotEmpty && tag[0] == 'd').length == + 1 && + data?['name'] is String && + parallelism is int && + parallelism >= 0 && + parallelism <= 4294967295 && + const ['owner-only', 'allowlist', 'anyone', 'nobody'].contains(mode) && + allowlist != null && + const [ + 'persona_id', + 'system_prompt', + 'model', + 'provider', + 'persona_source_version', + ].every((key) => data?[key] == null || data?[key] is String); + agents[entry.key] = AgentDirectoryEntry( + pubkey: entry.key, + ownerPubkey: event.pubkey, + displayName: valid + ? data!['name'] as String + : agents[entry.key]?.displayName, + respondTo: valid ? mode as String : 'nobody', + respondToAllowlist: valid ? allowlist : const [], + channelIds: agents[entry.key]?.channelIds ?? const [], + ); + } + return agents.values.toList(); +} + +List? _stringList(Object? value) => + value is List && value.every((v) => v is String) + ? value.cast().map((v) => v.toLowerCase()).toList() + : null; diff --git a/mobile/lib/shared/profile/user_cache_provider.dart b/mobile/lib/shared/profile/user_cache_provider.dart index c974b4a055f..91131c888d0 100644 --- a/mobile/lib/shared/profile/user_cache_provider.dart +++ b/mobile/lib/shared/profile/user_cache_provider.dart @@ -181,7 +181,7 @@ class UserCacheNotifier extends Notifier> { avatarUrl: data.avatarUrl, about: data.about, nip05Handle: data.nip05, - ownerPubkey: verifiedOaOwnerPubkey(event.tags, event.pubkey), + ownerPubkey: verifiedOaOwnerPubkey(event), ); } } diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 2bd6face240..17d83c804d8 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -887,6 +887,7 @@ void main() { _profileEvent( id: 'newer-agent', pubkey: agent.public, + secretKey: agent.secret, createdAt: 2, name: 'Agent', tags: [_authTag(owner, agent.public)], @@ -14420,15 +14421,26 @@ NostrEvent _profileEvent({ required int createdAt, required String name, List> tags = const [], -}) => NostrEvent( - id: id, - pubkey: pubkey, - createdAt: createdAt, - kind: 0, - tags: tags, - content: jsonEncode({'name': name}), - sig: 'sig', -); + String? secretKey, +}) => secretKey != null + ? NostrEvent.fromJson( + nostr.Event.from( + kind: 0, + content: jsonEncode({'name': name}), + secretKey: secretKey, + createdAt: createdAt, + tags: tags, + ).toMap(), + ) + : NostrEvent( + id: id, + pubkey: pubkey, + createdAt: createdAt, + kind: 0, + tags: tags, + content: jsonEncode({'name': name}), + sig: 'sig', + ); List _authTag(nostr.Keys owner, String agentPubkey) { final digest = SHA256Digest().process( diff --git a/mobile/test/features/profile/profile_provider_test.dart b/mobile/test/features/profile/profile_provider_test.dart index 5638656b715..239dc808a28 100644 --- a/mobile/test/features/profile/profile_provider_test.dart +++ b/mobile/test/features/profile/profile_provider_test.dart @@ -23,21 +23,21 @@ void main() { const ['custom', 'preserve-tag'], ]; final relaySession = _ProfileRelaySession( - NostrEvent( - id: 'profile-1', - pubkey: keys.public, - createdAt: 1, - kind: EventKind.profile, - tags: profileTags, - content: jsonEncode({ - 'name': 'alice', - 'display_name': 'Alice', - 'about': 'Building Buzz', - 'picture': 'https://relay.example/alice.png', - 'nip05': 'alice@example.com', - 'custom': 'preserve-me', - }), - sig: 'sig', + NostrEvent.fromJson( + nostr.Event.from( + secretKey: keys.secret, + createdAt: 1, + kind: EventKind.profile, + tags: profileTags, + content: jsonEncode({ + 'name': 'alice', + 'display_name': 'Alice', + 'about': 'Building Buzz', + 'picture': 'https://relay.example/alice.png', + 'nip05': 'alice@example.com', + 'custom': 'preserve-me', + }), + ).toMap(), ), ); final container = ProviderContainer( diff --git a/mobile/test/shared/crypto/nip_oa_test.dart b/mobile/test/shared/crypto/nip_oa_test.dart index 55577fe848c..3bb9e510113 100644 --- a/mobile/test/shared/crypto/nip_oa_test.dart +++ b/mobile/test/shared/crypto/nip_oa_test.dart @@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:nostr/nostr.dart' as nostr; import 'package:pointycastle/digests/sha256.dart'; import 'package:buzz/shared/crypto/nip_oa.dart'; +import 'package:buzz/shared/relay/relay.dart'; String _sha256Hex(String input) { final digest = SHA256Digest().process(Uint8List.fromList(utf8.encode(input))); @@ -21,6 +22,21 @@ List authTag( return ['auth', owner.public, conditions, sig]; } +NostrEvent profile( + nostr.Keys agent, + List> tags, { + int createdAt = 100, + int kind = 0, +}) => NostrEvent.fromJson( + nostr.Event.from( + kind: kind, + content: '{}', + secretKey: agent.secret, + createdAt: createdAt, + tags: tags, + ).toMap(), +); + void main() { final owner = nostr.Keys.generate(); final agent = nostr.Keys.generate(); @@ -28,7 +44,7 @@ void main() { test('returns the owner pubkey for a valid auth tag', () { final tag = authTag(owner, agent.public); expect( - verifiedOaOwnerPubkey([tag], agent.public), + verifiedOaOwnerPubkey(profile(agent, [tag])), owner.public.toLowerCase(), ); }); @@ -36,7 +52,7 @@ void main() { test('accepts valid conditions strings', () { final tag = authTag(owner, agent.public, conditions: 'kind=0'); expect( - verifiedOaOwnerPubkey([tag], agent.public), + verifiedOaOwnerPubkey(profile(agent, [tag])), owner.public.toLowerCase(), ); }); @@ -44,7 +60,7 @@ void main() { test('rejects a signature over a different agent pubkey', () { final otherAgent = nostr.Keys.generate(); final tag = authTag(owner, otherAgent.public); - expect(verifiedOaOwnerPubkey([tag], agent.public), isNull); + expect(verifiedOaOwnerPubkey(profile(agent, [tag])), isNull); }); test('rejects a tampered signature', () { @@ -55,25 +71,106 @@ void main() { 1, tampered[3][0] == '0' ? '1' : '0', ); - expect(verifiedOaOwnerPubkey([tampered], agent.public), isNull); + expect(verifiedOaOwnerPubkey(profile(agent, [tampered])), isNull); }); test('rejects self-attestation', () { final tag = authTag(agent, agent.public); - expect(verifiedOaOwnerPubkey([tag], agent.public), isNull); + expect(verifiedOaOwnerPubkey(profile(agent, [tag])), isNull); }); test('rejects malformed conditions', () { final tag = authTag(owner, agent.public, conditions: 'kind=abc'); - expect(verifiedOaOwnerPubkey([tag], agent.public), isNull); + expect(verifiedOaOwnerPubkey(profile(agent, [tag])), isNull); }); test('ignores unrelated tags', () { expect( - verifiedOaOwnerPubkey([ - ['p', owner.public], - ], agent.public), + verifiedOaOwnerPubkey( + profile(agent, [ + ['p', owner.public], + ]), + ), isNull, ); }); + test('rejects duplicate auth tags, including malformed companions', () { + final tag = authTag(owner, agent.public); + for (final duplicate in [ + tag, + ['auth'], + ['auth', 'invalid'], + ]) { + expect(verifiedOaOwnerPubkey(profile(agent, [tag, duplicate])), isNull); + expect(verifiedOaOwnerPubkey(profile(agent, [duplicate, tag])), isNull); + } + }); + + test('conditions evaluate the signed profile time, with strict bounds', () { + for (final conditions in [ + '', + 'kind=0', + 'created_at>99&created_at<101', + 'created_at<4294967295', + ]) { + expect( + verifiedOaOwnerPubkey( + profile(agent, [ + authTag(owner, agent.public, conditions: conditions), + ]), + ), + owner.public, + ); + } + for (final conditions in [ + 'kind=1', + 'created_at>100', + 'created_at<100', + 'kind=65536', + 'kind=00', + 'kind=+0', + 'created_at<4294967296', + 'kind=0&', + ' kind=0', + 'kind=0&kind=1', + ]) { + expect( + verifiedOaOwnerPubkey( + profile(agent, [ + authTag(owner, agent.public, conditions: conditions), + ]), + ), + isNull, + reason: conditions, + ); + } + }); + + test( + 'rejects noncanonical owner/signature and invalid profile envelopes', + () { + final tag = authTag(owner, agent.public); + for (final index in [1, 3]) { + final uppercase = [...tag]; + uppercase[index] = uppercase[index].toUpperCase(); + expect(verifiedOaOwnerPubkey(profile(agent, [uppercase])), isNull); + } + final valid = profile(agent, [tag]); + for (final patch in [ + {'content': 'forged'}, + {'created_at': 101}, + {'id': '0' * 64}, + {'sig': '0' * 128}, + {'pubkey': owner.public}, + ]) { + expect( + verifiedOaOwnerPubkey( + NostrEvent.fromJson({...valid.toJson(), ...patch}), + ), + isNull, + ); + } + expect(verifiedOaOwnerPubkey(profile(agent, [tag], kind: 1)), isNull); + }, + ); } diff --git a/mobile/test/shared/mentions/agent_owners_test.dart b/mobile/test/shared/mentions/agent_owners_test.dart new file mode 100644 index 00000000000..7a4d26308fb --- /dev/null +++ b/mobile/test/shared/mentions/agent_owners_test.dart @@ -0,0 +1,86 @@ +import 'package:buzz/features/channels/channel_management_provider.dart'; +import 'package:buzz/shared/mentions/agent_identity_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:nostr/nostr.dart' as nostr; + +import '../crypto/nip_oa_test.dart' show authTag, profile; + +void main() { + final owner = nostr.Keys.generate(); + final agent = nostr.Keys.generate(); + final owned = profile(agent, [authTag(owner, agent.public)]); + final revoked = profile(agent, [], createdAt: 101); + final tampered = NostrEvent.fromJson({ + ...profile(agent, [authTag(owner, agent.public)], createdAt: 102).toJson(), + 'content': 'forged', + }); + + Future> owners(List events) async { + final container = ProviderContainer( + overrides: [ + agentDirectoryProvider.overrideWith( + (ref) async => [ + AgentDirectoryEntry(pubkey: agent.public, displayName: 'Agent'), + ], + ), + relaySessionProvider.overrideWith(() => _Profiles(events)), + ], + ); + try { + return await container.read(agentOwnersProvider.future); + } finally { + container.dispose(); + } + } + + test( + 'directory resolves only the latest ownership, never older fallback', + () async { + expect(await owners([owned]), {agent.public: owner.public}); + for (final latest in [revoked, tampered]) { + for (final events in [ + [owned, latest], + [latest, owned], + ]) { + expect(await owners(events), isEmpty); + expect( + directoryUsersFromProfileEvents(events).single.isAgent, + isFalse, + ); + } + } + }, + ); + + test( + 'same-second owner revocation is deterministic across directory paths', + () async { + final other = profile(agent, []); + final expected = owned.id.compareTo(other.id) < 0; + for (final events in [ + [owned, other], + [other, owned], + ]) { + expect((await owners(events)).containsKey(agent.public), expected); + expect( + directoryUsersFromProfileEvents(events).single.isAgent, + expected, + ); + } + }, + ); +} + +class _Profiles extends RelaySessionNotifier { + _Profiles(this.events); + final List events; + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async => events; +} diff --git a/mobile/test/shared/mentions/agent_policy_test.dart b/mobile/test/shared/mentions/agent_policy_test.dart new file mode 100644 index 00000000000..85cdc6faef8 --- /dev/null +++ b/mobile/test/shared/mentions/agent_policy_test.dart @@ -0,0 +1,280 @@ +import 'dart:convert'; + +import 'package:buzz/features/channels/channel_management_provider.dart'; +import 'package:buzz/features/channels/mentions/mention_candidates.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; +import 'package:buzz/shared/profile/user_profile.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; + +import '../crypto/nip_oa_test.dart' show authTag, profile; + +NostrEvent signed( + nostr.Keys key, + int kind, + Object content, { + int time = 100, + List> tags = const [], +}) => NostrEvent.fromJson( + nostr.Event.from( + kind: kind, + content: content is String ? content : jsonEncode(content), + secretKey: key.secret, + createdAt: time, + tags: tags, + ).toMap(), +); + +class PolicySession extends RelaySessionNotifier { + PolicySession(this.events, {this.failPolicy = false}); + final List events; + final bool failPolicy; + final queries = []; + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async => events.where((e) => e.kind == 10100).toList(); + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) async { + expect(filters.length, lessThanOrEqualTo(10)); + queries.addAll(filters); + if (failPolicy && filters.any((f) => f.kinds.contains(30177))) { + throw StateError('policy read failed'); + } + return events + .where( + (event) => filters.any( + (f) => + f.kinds.contains(event.kind) && + f.authors!.contains(event.pubkey) && + (f.tags['#d'] == null || + f.tags['#d']!.contains(event.getTagValue('d'))), + ), + ) + .toList(); + } +} + +void main() { + final owner = nostr.Keys.generate(); + final agent = nostr.Keys.generate(); + final stranger = nostr.Keys.generate(); + final owned = profile(agent, [authTag(owner, agent.public)]); + final runtime = signed(agent, 10100, { + 'name': 'runtime', + 'respond_to': 'anyone', + 'channel_ids': ['channel'], + }); + NostrEvent policy(Object content, {int time = 100, nostr.Keys? author}) => + signed( + author ?? owner, + 30177, + content, + time: time, + tags: [ + ['d', agent.public], + ], + ); + final allow = policy({ + 'name': 'Agent', + 'parallelism': 1, + 'respond_to': 'owner-only', + }); + + Future> directory( + List events, { + bool failPolicy = false, + void Function(PolicySession)? inspect, + }) async { + final session = PolicySession(events, failPolicy: failPolicy); + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => session)], + ); + try { + final result = await container.read(agentDirectoryProvider.future); + inspect?.call(session); + return result; + } finally { + container.dispose(); + } + } + + test( + 'production directory reads exact authenticated owner coordinates', + () async { + final agents = await directory( + [runtime, owned, allow], + inspect: (session) { + final query = session.queries.singleWhere( + (q) => q.kinds.contains(30177), + ); + expect(query.authors, [owner.public]); + expect(query.tags, { + '#d': [agent.public], + }); + expect(query.limit, 1); + }, + ); + expect(agents.single.ownerPubkey, owner.public); + expect(agents.single.respondTo, 'owner-only'); + expect(agents.single.displayName, 'Agent'); + expect(agentIsSharedWithUser(agents.single, {}, owner.public), isTrue); + expect( + agentIsSharedWithUser(agents.single, {'channel'}, stranger.public), + isFalse, + ); + }, + ); + + test( + 'latest malformed policy reserves deny-all, never runtime/older fallback', + () async { + for (final body in [ + '', + '{}', + {'name': 'Agent', 'parallelism': 1, 'respond_to': 'unknown'}, + { + 'name': 'Agent', + 'parallelism': 1, + 'respond_to': 'anyone', + 'respond_to_allowlist': [3], + }, + {'name': 'Agent', 'parallelism': -1, 'respond_to': 'anyone'}, + ]) { + final bad = policy(body, time: 101); + for (final policies in [ + [allow, bad], + [bad, allow], + ]) { + final result = await directory([runtime, owned, ...policies]); + expect(result.single.respondTo, 'nobody'); + expect( + agentIsSharedWithUser(result.single, {'channel'}, owner.public), + isFalse, + ); + } + } + }, + ); + + test( + 'same-second policy ties are independent of response ordering', + () async { + final deny = policy({ + 'name': 'Agent', + 'parallelism': 1, + 'respond_to': 'nobody', + }); + for (final policies in [ + [allow, deny], + [deny, allow], + ]) { + expect( + (await directory([runtime, owned, ...policies])).single.respondTo, + allow.id.compareTo(deny.id) < 0 ? 'owner-only' : 'nobody', + ); + } + }, + ); + + test( + 'tampered authenticated policy cannot revive runtime permission', + () async { + final tampered = NostrEvent.fromJson({ + ...allow.toJson(), + 'content': '{}', + }); + expect( + (await directory([runtime, owned, tampered])).single.respondTo, + 'nobody', + ); + }, + ); + + test( + 'foreign policy cannot override a headless runtime; revoked owner is not revived', + () async { + final foreign = policy({}, author: stranger); + expect( + (await directory([runtime, owned, foreign])).single.respondTo, + 'anyone', + ); + final revoked = profile(agent, [], createdAt: 101); + final result = await directory([runtime, owned, revoked, allow]); + expect(result.single.ownerPubkey, isNull); + expect(result.single.respondTo, 'anyone'); // OSS headless compatibility. + }, + ); + + test( + 'policy read failure propagates instead of falling back to runtime', + () async { + await expectLater( + directory([runtime, owned, allow], failPolicy: true), + throwsStateError, + ); + }, + ); + + test('latest tampered runtime never revives older valid runtime', () async { + final bad = NostrEvent.fromJson({...runtime.toJson(), 'created_at': 102}); + expect(await directory([runtime, bad]), isEmpty); + }); + + test( + 'policy denial applies to member, non-member and owned search candidates', + () async { + final deny = policy({ + 'name': 'Agent', + 'parallelism': 1, + 'respond_to': 'nobody', + }); + final agents = await directory([runtime, owned, deny]); + for (final members in [ + [], + [ + ChannelMember( + pubkey: agent.public, + role: 'bot', + joinedAt: DateTime(2026), + ), + ], + ]) { + final candidates = buildMentionCandidates( + members: members, + relayAgents: agents, + sharedChannelIds: {'channel'}, + userCache: {}, + ownerByAgentPubkey: {agent.public: owner.public}, + currentPubkey: owner.public, + searchResults: [ + UserProfile(pubkey: agent.public, ownerPubkey: owner.public), + ], + ); + expect(candidates, isEmpty); + } + }, + ); + + test('owner is allowed by each supported mode except nobody', () async { + for (final mode in ['owner-only', 'allowlist', 'anyone', 'nobody']) { + final agents = await directory([ + runtime, + owned, + policy({'name': 'Agent', 'parallelism': 1, 'respond_to': mode}), + ]); + expect( + agentIsSharedWithUser(agents.single, {}, owner.public), + mode != 'nobody', + ); + } + }); +} diff --git a/mobile/test/shared/profile/user_cache_provider_test.dart b/mobile/test/shared/profile/user_cache_provider_test.dart index 9a270861076..ee70f90a9ac 100644 --- a/mobile/test/shared/profile/user_cache_provider_test.dart +++ b/mobile/test/shared/profile/user_cache_provider_test.dart @@ -60,6 +60,7 @@ void main() { _profileEvent( id: 'newer-agent', pubkey: agent.public, + secretKey: agent.secret, createdAt: 2, name: 'Agent', tags: [_authTag(owner, agent.public)], @@ -101,6 +102,7 @@ void main() { _profileEvent( id: 'older-agent', pubkey: agent.public, + secretKey: agent.secret, createdAt: 1, name: 'Agent', tags: [_authTag(owner, agent.public)], @@ -165,16 +167,27 @@ NostrEvent _profileEvent({ required String name, String pubkey = 'agent', List> tags = const [], + String? secretKey, int kind = 0, -}) => NostrEvent( - id: id, - pubkey: pubkey, - createdAt: createdAt, - kind: kind, - tags: tags, - content: jsonEncode({'name': name}), - sig: 'sig', -); +}) => secretKey != null + ? NostrEvent.fromJson( + nostr.Event.from( + kind: 0, + content: jsonEncode({'name': name}), + secretKey: secretKey, + createdAt: createdAt, + tags: tags, + ).toMap(), + ) + : NostrEvent( + id: id, + pubkey: pubkey, + createdAt: createdAt, + kind: kind, + tags: tags, + content: jsonEncode({'name': name}), + sig: 'sig', + ); List _authTag(nostr.Keys owner, String agentPubkey) { final digest = SHA256Digest().process(