Filter archived identities from Solo agent choices

Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-10-21 21:45:31 -07:00
parent fa042d24b4
commit 7c60c0aee9
7 changed files with 319 additions and 4 deletions

View File

@@ -101,7 +101,10 @@ Future<List<AgentDirectoryEntry>> _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<NostrEvent> _verifyProfiles(List<NostrEvent> events) {

View File

@@ -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<MeSync> meSyncProvider = Provider<MeSync>((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';

View File

@@ -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<Set<String>> 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 = <int>[];
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<String> _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],
};
}

View File

@@ -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;

View File

@@ -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<List<NostrEvent>> queryRelay(
List<NostrFilter> filters, {
Duration timeout = const Duration(seconds: 8),
}) async => [profile];
}) async => filters.single.kinds.single == EventKind.archivedIdentities
? []
: [profile];
@override
Future<List<NostrEvent>> 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 = <MethodChannel>[];
const platformViews = MethodChannel('flutter/platform_views');
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(

View File

@@ -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 = <int>[];
final requestedTags = <Map<String, dynamic>>[];
final requestedAuthors = <List<String>?>[];
List<NostrEvent> archives = [];
_Relay(this.pages);
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
@@ -22,6 +26,9 @@ class _Relay extends RelaySessionNotifier {
List<NostrFilter> 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)];

View File

@@ -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<NostrEvent> events = [];
final String authority;
_Relay(this.authority);
@override
Future<List<NostrEvent>> queryRelay(
List<NostrFilter> 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<List<String>>? 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<String, dynamic>? 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<Exception>(), isA<StateError>())),
);
}
},
);
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);
},
);
}