mirror of
https://github.com/block/buzz.git
synced 2026-11-04 16:31:38 +00:00
Filter archived identities from Solo agent choices
Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
116
mobile/lib/shared/relay/identity_archives.dart
Normal file
116
mobile/lib/shared/relay/identity_archives.dart
Normal 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],
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)];
|
||||
|
||||
128
mobile/test/shared/relay/identity_archives_test.dart
Normal file
128
mobile/test/shared/relay/identity_archives_test.dart
Normal 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);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user