fix(mobile): request hosted relay assertions only when NIP-FI is required (#9225)

Port the desktop NIP-FI requirement rule to mobile: hosted relay connections request an account assertion only when the relay's NIP-11 document requires NIP-FI, so communities that have not enabled it keep connecting with BUZZ_HOSTED_RELAY_AUTH on.

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Tom Brow
2026-11-06 18:57:05 -08:00
committed by GitHub
parent 96af488d96
commit b58ee08404
17 changed files with 402 additions and 51 deletions

View File

@@ -790,7 +790,7 @@ class RemoteAgentController extends ChangeNotifier {
final config = RelayConfig(baseUrl: relay, nsec: key.nsec);
await submitSignedEventOnce(
wsUrl: config.wsUrl,
headers: await relayAuth.headers(config),
headers: await relayAuth.headers(config, fresh: true),
nsec: key.nsec,
kind: 30177,
content: jsonEncode({
@@ -813,7 +813,7 @@ class RemoteAgentController extends ChangeNotifier {
final config = RelayConfig(baseUrl: relay, nsec: key.nsec);
await submitSignedEventOnce(
wsUrl: config.wsUrl,
headers: await relayAuth.headers(config),
headers: await relayAuth.headers(config, fresh: true),
nsec: key.nsec,
kind: 5,
content: '',

View File

@@ -179,7 +179,11 @@ Future<void> _deactivateCommunityPushLease(
({required kind, required content, required tags, createdAt}) async =>
submitSignedEventOnce(
wsUrl: wsUrl,
headers: await relayAuth.headers(relayConfig, background: true),
headers: await relayAuth.headers(
relayConfig,
background: true,
fresh: true,
),
nsec: nsec,
kind: kind,
content: content,

View File

@@ -162,6 +162,7 @@ class HostedAdmission {
try {
socket.headers = await relayAuth.headers(
RelayConfig(baseUrl: community.relayUrl, nsec: community.nsec),
fresh: true,
);
await socket.connect().timeout(const Duration(seconds: 30));
if (socket.state != SocketState.connected) throw AccountFailure.denied;

View File

@@ -8,6 +8,7 @@ import '../relay/relay_provider.dart';
import 'builderlab_account.dart';
import 'account_identity_store.dart';
import 'hosted_environment.dart';
import 'nip_fi_discovery.dart';
import 'organization_environment.dart';
import 'organization_key_backup.dart';
import '../push/push_bridge.dart';
@@ -61,9 +62,10 @@ final hostedHttpClientFactoryProvider = Provider<http.Client Function()>((ref) {
});
/// One account session per realm, with a short-lived assertion for the current
/// relay/key. Hosted communities use [account] when [enabled]; the build's
/// organization relays use [organization]. Enable alongside backend issuance;
/// legacy and self-hosted builds stay usable.
/// relay/key. Hosted communities use [account] when [enabled] and their relay's
/// NIP-FI discovery requires evidence; the build's organization relays always
/// use [organization]. Enable alongside backend issuance; legacy and
/// self-hosted builds stay usable.
class HostedRelayAuth extends ChangeNotifier {
HostedRelayAuth(
this.account, {
@@ -72,7 +74,18 @@ class HostedRelayAuth extends ChangeNotifier {
this.organizationRelay = OrganizationEnvironment.protects,
this.enabled = const bool.fromEnvironment('BUZZ_HOSTED_RELAY_AUTH'),
DateTime Function()? now,
}) : _now = now ?? DateTime.now;
NipFiRequirementFetch? discover,
}) : _now = now ?? DateTime.now,
_discover = discover ?? _fetchRequirement;
static Future<bool> _fetchRequirement(Uri origin) async {
final client = http.Client();
try {
return await fetchNipFiRequirement(client, origin);
} finally {
client.close();
}
}
final BuilderlabAccountClient account;
final BuilderlabAccountClient? organization;
@@ -82,6 +95,12 @@ class HostedRelayAuth extends ChangeNotifier {
final bool Function(Uri relay) organizationRelay;
final bool enabled;
final DateTime Function() _now;
final NipFiRequirementFetch _discover;
// NIP-FI discovery per hosted relay origin: the read in flight, shared by
// concurrent callers, and the last result. The issuer refuses hosts that have
// not enabled NIP-FI, so those connect without evidence.
final _discovering = <String, Future<bool>>{};
final _required = <String, bool>{};
// One assertion per relay and key, so issuing for an inactive community (for
// example a background revocation) never evicts the active session's
// credentials that native media paths read synchronously.
@@ -130,7 +149,12 @@ class HostedRelayAuth extends ChangeNotifier {
/// recovery state. Null means protected metadata must wait for active auth.
Map<String, String>? cachedHeaders(RelayConfig config) {
if (_disposed) return null;
if (!protects(config)) return const {};
final client = _accountFor(config);
if (client == null) return const {};
if (_hostedOrigin(config, client) case final origin?
when _required[origin] == false) {
return const {};
}
final pubkey = pubkeyFromNsec(config.nsec);
if (pubkey == null) return null;
final cached =
@@ -139,7 +163,7 @@ class HostedRelayAuth extends ChangeNotifier {
pubkey: pubkey,
)];
if (cached == null ||
!identical(cached.account, _accountFor(config)?.account) ||
!identical(cached.account, client.account) ||
!cached.expiry.isAfter(_now())) {
return null;
}
@@ -176,13 +200,23 @@ class HostedRelayAuth extends ChangeNotifier {
/// foreground use never inherits an assertion whose key was not backed up.
/// Without an assertion it gets no headers rather than a failure, so the
/// relay decides.
///
/// A hosted relay gets an assertion only when its NIP-FI discovery requires
/// one. Every caller opening a new socket passes [fresh] to reread
/// discovery, so a community that changes enforcement is picked up on its
/// next connect; HTTP requests reuse the latest discovery.
Future<Map<String, String>> headers(
RelayConfig config, {
bool background = false,
bool fresh = false,
}) async {
final client = _accountFor(config);
if (client == null) return const {};
try {
if (_hostedOrigin(config, client) case final origin?
when !await _requires(origin, fresh)) {
return const {};
}
return await _headers(config, client, background);
} on AccountFailure catch (failure) {
// Cleanup still reaches a relay that does not enforce assertions; an
@@ -198,6 +232,29 @@ class HostedRelayAuth extends ChangeNotifier {
}
}
/// The relay origin whose discovery decides, or null for organization relays,
/// which always use their own issuer.
String? _hostedOrigin(RelayConfig config, BuilderlabAccountClient client) =>
identical(client, organization) ? null : Uri.parse(config.baseUrl).origin;
Future<bool> _requires(String origin, bool fresh) async {
final known = _required[origin];
if (!fresh && known != null) return known;
final pending = _discovering[origin] ??= _discover(Uri.parse(origin));
try {
return _required[origin] = await pending;
} on Exception catch (error) {
// The last answer stays for requests. Without one, or for a new
// connection, retry with backoff rather than guessing.
debugPrint('NIP-FI discovery failed for $origin: $error');
throw AccountFailure.unavailable;
} finally {
if (identical(_discovering[origin], pending)) {
_discovering.remove(origin);
}
}
}
Future<Map<String, String>> _headers(
RelayConfig config,
BuilderlabAccountClient client,
@@ -403,6 +460,8 @@ class HostedRelayAuth extends ChangeNotifier {
void dispose() {
_disposed = true;
_cached.clear();
_discovering.clear();
_required.clear();
super.dispose();
}
}

View File

@@ -0,0 +1,47 @@
import 'package:http/http.dart' as http;
import '../relay/nip11_document.dart';
/// Whether the relay at [origin] requires NIP-FI admission evidence, read from
/// its NIP-11 document. Throws on any failure or malformed document.
typedef NipFiRequirementFetch = Future<bool> Function(Uri origin);
/// Reads [origin]'s NIP-11 document from its root, without following
/// redirects; see [fetchNip11Document].
Future<bool> fetchNipFiRequirement(
http.Client client,
Uri origin, {
Duration deadline = nip11Deadline,
}) async => parseNipFiRequirement(
await fetchNip11Document(
client,
Uri.parse(origin.origin).replace(path: '/'),
deadline: deadline,
followRedirects: false,
),
);
/// NIP-FI Discovery: `limitation.federated_identity` advertises support, as a
/// shadow-mode host does too; the host-specific `requires_nip_fi` says this
/// community requires evidence. A relay without the flag requires what it
/// supports. Same requirement rule as buzz-app's desktop discovery.
bool parseNipFiRequirement(Object? document) {
if (document is! Map<String, dynamic>) {
throw const FormatException('NIP-11 document is not an object');
}
final supports = switch (document['limitation']) {
null => false,
final Map<String, dynamic> limitation =>
switch (limitation['federated_identity']) {
null || false => false,
true => true,
_ => throw const FormatException('Malformed federated_identity'),
},
_ => throw const FormatException('Malformed NIP-11 limitation'),
};
return switch (document['requires_nip_fi']) {
null => supports,
final bool requires => requires,
_ => throw const FormatException('Malformed requires_nip_fi'),
};
}

View File

@@ -160,6 +160,7 @@ final huddleTransportFactoryProvider = Provider<HuddleTransportFactory>((ref) {
parameters: parameters,
headers: () => auth.headers(
RelayConfig(baseUrl: parameters.relayWebSocketUrl, nsec: parameters.nsec),
fresh: true,
),
);
});

View File

@@ -553,6 +553,7 @@ Future<void> publishBuzzPushLeaseRevocation(
headers: await relayAuth.headers(
RelayConfig(baseUrl: record.relayUrl, nsec: record.nsec),
background: true,
fresh: true,
),
nsec: record.nsec,
kind: kind,

View File

@@ -0,0 +1,50 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
const _maxDocumentBytes = 256 * 1024;
/// How long a NIP-11 read may take, headers and body together.
const nip11Deadline = Duration(seconds: 10);
/// Reads and decodes the NIP-11 document at [uri] with a size cap. One
/// wall-clock deadline covers headers and the whole body, so a relay trickling
/// bytes cannot keep the read in flight forever. No credential goes on the
/// request. Throws on a non-200 status, an oversized body, the deadline or
/// malformed JSON.
Future<Object?> fetchNip11Document(
http.Client client,
Uri uri, {
Duration deadline = nip11Deadline,
bool followRedirects = true,
}) async {
final abort = Completer<void>();
final request = http.AbortableRequest('GET', uri, abortTrigger: abort.future)
..followRedirects = followRedirects
..headers['Accept'] = 'application/nostr+json';
StreamIterator<List<int>>? body;
Future<List<int>> read() async {
final response = await client.send(request);
final iterator = body = StreamIterator(response.stream);
if (response.statusCode != 200) {
throw http.ClientException('NIP-11 status ${response.statusCode}', uri);
}
final bytes = <int>[];
while (await iterator.moveNext()) {
if (bytes.length + iterator.current.length > _maxDocumentBytes) {
throw const FormatException('NIP-11 exceeds size limit');
}
bytes.addAll(iterator.current);
}
return bytes;
}
try {
final bytes = await read().timeout(deadline);
return jsonDecode(utf8.decode(bytes));
} finally {
if (!abort.isCompleted) abort.complete();
unawaited(body?.cancel());
}
}

View File

@@ -627,7 +627,9 @@ class RelaySessionNotifier extends Notifier<SessionState> {
try {
final auth = ref.read(hostedRelayAuthProvider);
if (auth.protects(config)) socket.headers = await auth.headers(config);
if (auth.protects(config)) {
socket.headers = await auth.headers(config, fresh: true);
}
if (_disposed || generation != _connectionGeneration) return;
await socket.connect();
} on AccountFailure catch (error) {

View File

@@ -1,15 +1,10 @@
import 'dart:async';
import 'dart:convert';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
import '../relay/nip11_document.dart';
import '../relay/relay.dart';
import 'verified_profile.dart';
const _maxInfoBytes = 256 * 1024;
const _discoveryTimeout = Duration(seconds: 10);
/// Fetches and parses the community's NIP-11 document. Throws on any failure
/// or malformed response; a thrown error never changes the last known state.
typedef VerifiedProfileDiscoveryFetch =
@@ -21,42 +16,14 @@ final verifiedProfileHttpClientProvider = Provider<http.Client>((ref) {
return client;
});
/// Reads the NIP-11 document at [uri] with a size cap. One wall-clock
/// deadline covers headers and the whole body, so a relay trickling bytes
/// cannot keep discovery in flight forever.
/// Reads and parses the NIP-11 document at [uri]; see [fetchNip11Document].
Future<VerifiedProfileDiscovery> fetchVerifiedProfileDiscovery(
http.Client client,
Uri uri, {
Duration deadline = _discoveryTimeout,
}) async {
final abort = Completer<void>();
final request = http.AbortableRequest('GET', uri, abortTrigger: abort.future)
..headers['Accept'] = 'application/nostr+json';
StreamIterator<List<int>>? body;
Future<List<int>> read() async {
final response = await client.send(request);
final iterator = body = StreamIterator(response.stream);
if (response.statusCode != 200) {
throw http.ClientException('NIP-11 status ${response.statusCode}', uri);
}
final bytes = <int>[];
while (await iterator.moveNext()) {
if (bytes.length + iterator.current.length > _maxInfoBytes) {
throw const FormatException('NIP-11 exceeds size limit');
}
bytes.addAll(iterator.current);
}
return bytes;
}
try {
final bytes = await read().timeout(deadline);
return parseVerifiedProfileDiscovery(jsonDecode(utf8.decode(bytes)));
} finally {
if (!abort.isCompleted) abort.complete();
unawaited(body?.cancel());
}
}
Duration deadline = nip11Deadline,
}) async => parseVerifiedProfileDiscovery(
await fetchNip11Document(client, uri, deadline: deadline),
);
final verifiedProfileDiscoveryFetchProvider =
Provider<VerifiedProfileDiscoveryFetch>((ref) {

View File

@@ -160,6 +160,7 @@ class _FakeHostedRelayAuth extends HostedRelayAuth {
Future<Map<String, String>> headers(
RelayConfig config, {
bool background = false,
bool fresh = false,
}) async {
await gate?.future;
return {'Nostr-Federated-Identity': 'Bearer hosted-token'};

View File

@@ -818,6 +818,73 @@ void main() {
requiresBuilderlab: false,
);
journey(
'a hosted community gets assertions only once its relay requires NIP-FI',
(tester, f) async {
f.relayAuthEnabled = true;
f.nipFiRequired = false;
await f.initialize();
final community = Community.create(
name: 'One',
relayUrl: 'wss://one.communities.buzz.xyz',
pubkey: f.keys.public,
nsec: f.keys.nsec,
);
await f.storage.save(community);
await f.storage.saveActiveId(community.id);
await f.mount(tester, openEntry: false);
final sessionSubscription = f.container.listen(
relaySessionProvider,
(_, _) {},
);
addTearDown(sessionSubscription.close);
final session = f.container.read(relaySessionProvider.notifier);
await f.settle(tester);
// The issuer refuses hosts without NIP-FI; the app never asks it.
expect(f.socketHeaders, isNotEmpty);
expect(f.socketHeaders, everyElement(isEmpty));
await session.queryRelay(const []);
expect(f.sent('/query'), hasLength(1));
expect(f.sent('/identity/assertions'), isEmpty);
expect(
find.textContaining('cannot reconnect to this community'),
findsNothing,
);
// Cached-only reads (saved profile cards, native media) go out without
// an assertion instead of waiting for one that will never come.
final auth = f.container.read(hostedRelayAuthProvider);
final config = f.container.read(relayConfigProvider);
expect(auth.cachedHeaders(config), isEmpty);
// Unreachable discovery fails transiently, never as a denial.
f.nipFiRequired = null;
await session.reconnect();
await f.settle(tester);
expect(f.container.read(hostedRelayAuthProvider).failure, isNull);
// Requests keep the last answer instead of failing with discovery.
await session.queryRelay(const []);
expect(f.sent('/query'), hasLength(2));
expect(f.sent('/identity/assertions'), isEmpty);
// Once the community requires NIP-FI, its next connection carries one.
f.nipFiRequired = true;
final discoveries = f.discoveries;
await session.reconnect();
await f.settle(tester);
expect(f.discoveries, greaterThan(discoveries));
expect(f.socketHeaders.last, {
'Nostr-Federated-Identity': 'Bearer synthetic-assertion',
});
expect(f.sent('/identity/assertions'), hasLength(1));
await session.queryRelay(const []);
expect(f.sent('/identity/assertions'), hasLength(1));
expect(auth.cachedHeaders(config), {
'Nostr-Federated-Identity': 'Bearer synthetic-assertion',
});
},
requiresAuthDomain: true,
);
journey(
'saved community signs in again without changing identity or onboarding',
(tester, f) async {

View File

@@ -75,6 +75,10 @@ class HostedJourneyFixture {
int secondRelayFailures = 0;
DateTime assertionNow = DateTime.now();
bool relayAuthEnabled = false;
/// What hosted relays' NIP-FI discovery answers; null fails discovery.
bool? nipFiRequired = true;
int discoveries = 0;
bool expiredSession = false;
bool denyAssertion = false;
bool refuseUpgrade = false;
@@ -195,7 +199,7 @@ class HostedJourneyFixture {
HostedEnvironment.requiresAuth(request.url.host)) {
expect(
request.headers['Nostr-Federated-Identity'],
'Bearer synthetic-assertion',
nipFiRequired == true ? 'Bearer synthetic-assertion' : isNull,
);
expect(request.headers.containsKey('X-BB-Session-Credential'), isFalse);
}
@@ -482,6 +486,11 @@ class HostedJourneyFixture {
organizationEnabled && relay.host == organizationRelayHost,
enabled: true,
now: () => assertionNow,
discover: (_) async {
discoveries++;
return nipFiRequired ??
(throw http.ClientException('NIP-11 unreachable'));
},
);
ref.onDispose(auth.dispose);
return auth;
@@ -734,7 +743,8 @@ class _RelaySocket extends RelaySocket {
host == HostedJourneyFixture.organizationRelayHost
// Signed out connects without one and lets the relay decide.
? anyOf(isNull, 'Bearer organization-assertion')
: HostedEnvironment.requiresAuth(host)
: HostedEnvironment.requiresAuth(host) &&
fixture.nipFiRequired != false
? 'Bearer synthetic-assertion'
: isNull,
);

View File

@@ -0,0 +1,133 @@
import 'dart:async';
import 'dart:convert';
import 'package:buzz/shared/hosted/nip_fi_discovery.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
void main() {
test(
'NIP-FI requirement follows discovery, and malformed documents fail',
() {
final cases = <(Object?, bool?)>[
// Shadow mode advertises support without requiring evidence.
(
{
'limitation': {'federated_identity': true},
'requires_nip_fi': false,
},
false,
),
(
{
'limitation': {'federated_identity': true},
'requires_nip_fi': true,
},
true,
),
// Without the host flag, a relay requires what it supports.
(
{
'limitation': {'federated_identity': true},
},
true,
),
(
{
'limitation': {'federated_identity': false},
},
false,
),
({'limitation': <String, dynamic>{}}, false),
({'name': 'relay'}, false),
({'requires_nip_fi': true}, true),
// Null marks a malformed document.
(
{
'limitation': {'federated_identity': 'yes'},
},
null,
),
({'limitation': true}, null),
({'requires_nip_fi': 'true'}, null),
([], null),
('relay', null),
];
for (final (document, required) in cases) {
if (required == null) {
expect(
() => parseNipFiRequirement(document),
throwsFormatException,
reason: '$document',
);
} else {
expect(
parseNipFiRequirement(document),
required,
reason: '$document',
);
}
}
},
);
test(
'discovery reads the root document, bounded and uncredentialed',
() async {
final requests = <http.BaseRequest>[];
Future<bool> discover(
http.StreamedResponse Function() respond, {
Duration deadline = const Duration(seconds: 10),
}) => fetchNipFiRequirement(
MockClient.streaming((request, _) async {
requests.add(request);
return respond();
}),
Uri.parse('https://one.example.org/ignored?x=1'),
deadline: deadline,
);
http.StreamedResponse document(Object body, {int status = 200}) =>
http.StreamedResponse(
Stream.value(utf8.encode(jsonEncode(body))),
status,
);
expect(
await discover(
() => document({
'limitation': {'federated_identity': true},
'requires_nip_fi': false,
}),
),
isFalse,
);
expect(requests.single.url.toString(), 'https://one.example.org/');
expect(requests.single.headers, {'Accept': 'application/nostr+json'});
expect(requests.single.followRedirects, isFalse);
await expectLater(
discover(() => document({'error': 'unknown'}, status: 404)),
throwsA(isA<http.ClientException>()),
);
await expectLater(
discover(
() => http.StreamedResponse(
Stream.fromIterable(List.filled(300, List.filled(1024, 0x20))),
200,
),
),
throwsFormatException,
);
final stalled = StreamController<List<int>>();
addTearDown(stalled.close);
await expectLater(
discover(
() => http.StreamedResponse(stalled.stream, 200),
deadline: const Duration(milliseconds: 50),
),
throwsA(isA<TimeoutException>()),
);
},
);
}

View File

@@ -93,6 +93,7 @@ void main() {
organizationRelay: (relay) =>
OrganizationEnvironment.matches(relay, [relayHost]),
enabled: true,
discover: (_) async => true,
);
final orgHeaders = auth.headers(orgRelay);
await pumpEventQueue();

View File

@@ -95,6 +95,9 @@ void main() {
expect(auth.configs.single.nsec, keys.nsec);
// A removed community's failure must not hide the active one's recovery.
expect(auth.backgrounds.single, isTrue);
// A new socket rereads NIP-FI discovery, so a community that started
// enforcing since its last read still gets the assertion.
expect(auth.freshes.single, isTrue);
// Removing the community publishes its tombstone the same way.
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
@@ -504,14 +507,17 @@ class _AssertingRelayAuth extends HostedRelayAuth {
final configs = <RelayConfig>[];
final backgrounds = <bool>[];
final freshes = <bool>[];
@override
Future<Map<String, String>> headers(
RelayConfig config, {
bool background = false,
bool fresh = false,
}) async {
configs.add(config);
backgrounds.add(background);
freshes.add(fresh);
return {'Nostr-Federated-Identity': 'Bearer hosted-token'};
}
}

View File

@@ -575,6 +575,7 @@ class _SnapshotAuth extends HostedRelayAuth {
Future<Map<String, String>> headers(
RelayConfig config, {
bool background = false,
bool fresh = false,
}) async {
await beforeHeaders?.call();
return {'Nostr-Federated-Identity': 'assertion'};