Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 5 additions & 30 deletions mobile/lib/features/channels/channel_management_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -296,17 +296,7 @@ List<String> relayMemberPubkeysFromEvents(List<NostrEvent> events) {
/// Converts kind:0 events into a deduplicated, alphabetized people directory.
@visibleForTesting
List<DirectoryUser> directoryUsersFromProfileEvents(List<NostrEvent> events) {
final latestByPubkey = <String, NostrEvent>{};
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)
Expand All @@ -316,7 +306,7 @@ List<DirectoryUser> directoryUsersFromProfileEvents(List<NostrEvent> 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(
Expand Down Expand Up @@ -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) {
Expand Down
16 changes: 16 additions & 0 deletions mobile/lib/features/channels/mentions/mention_candidates.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ bool agentIsSharedWithUser(
Set<String> 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());
}
Expand Down Expand Up @@ -62,6 +67,11 @@ List<MentionCandidate> 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,
Expand Down Expand Up @@ -121,6 +131,12 @@ List<MentionCandidate> 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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <String, NostrEvent>{};
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),
];
Expand All @@ -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),
);
}

Expand Down
4 changes: 2 additions & 2 deletions mobile/lib/features/profile/profile_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ class ProfileNotifier extends AsyncNotifier<UserProfile?> {
avatarUrl: data.avatarUrl,
about: data.about,
nip05Handle: data.nip05,
ownerPubkey: verifiedOaOwnerPubkey(latest.tags, data.pubkey),
ownerPubkey: verifiedOaOwnerPubkey(latest),
);
_requireCurrentWriteContext(context);
_metadata = metadata;
Expand Down Expand Up @@ -233,7 +233,7 @@ class ProfileNotifier extends AsyncNotifier<UserProfile?> {
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);
Expand Down
95 changes: 59 additions & 36 deletions mobile/lib/shared/crypto/nip_oa.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand All @@ -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<List<String>> 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<String, NostrEvent> latestProfileEvents(Iterable<NostrEvent> events) {
final latest = <String, NostrEvent>{};
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=<n>`, `created_at<<n>`, or `created_at><n>` 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;

Expand All @@ -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;
Expand Down
27 changes: 27 additions & 0 deletions mobile/lib/shared/crypto/signed_event.dart
Original file line number Diff line number Diff line change
@@ -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;
}
}
11 changes: 8 additions & 3 deletions mobile/lib/shared/mentions/agent_identity_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -15,13 +18,15 @@ import '../../shared/relay/relay.dart';
class AgentDirectoryEntry {
final String pubkey;
final String? displayName;
final String? ownerPubkey;
final String? respondTo;
final List<String> respondToAllowlist;
final List<String> channelIds;

const AgentDirectoryEntry({
required this.pubkey,
this.displayName,
this.ownerPubkey,
this.respondTo,
this.respondToAllowlist = const [],
this.channelIds = const [],
Expand Down Expand Up @@ -66,7 +71,7 @@ final agentDirectoryProvider = FutureProvider<List<AgentDirectoryEntry>>((
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
Expand All @@ -80,8 +85,8 @@ final agentOwnersProvider = FutureProvider<Map<String, String>>((ref) async {
NostrFilters.profilesBatch([for (final agent in agents) agent.pubkey]),
);
final owners = <String, String>{};
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;
Expand Down
Loading
Loading