perf(mobile): sign navigation media requests off the UI isolate

Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-10-17 17:12:25 -07:00
committed by Tom Brow
parent 05a1568b54
commit ebc316b91b
4 changed files with 160 additions and 3 deletions

View File

@@ -30,7 +30,8 @@ final videoPreviewFrameLoaderProvider = Provider<VideoPreviewFrameLoader>((
ref,
) {
final auth = ref.watch(mediaGetAuthServiceProvider);
return (url) => _loadVideoPreviewFrame(url, headers: auth.headersFor(url));
return (url) async =>
_loadVideoPreviewFrame(url, headers: await auth.headersForAsync(url));
});
class _MessageVideoPreview extends HookConsumerWidget {

View File

@@ -1,5 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
@@ -37,6 +39,7 @@ class MediaGetAuthService {
Map<String, String>? _cachedHeaders;
DateTime? _refreshAt;
Future<void> _signingTail = Future<void>.value();
MediaGetAuthService({
required String baseUrl,
@@ -55,6 +58,35 @@ class MediaGetAuthService {
return _isRelayMediaUrl(uri, relayUri);
}
/// Mints fetch-time auth without doing cryptography on the UI isolate.
///
/// Image loading starts during layout. Serialize signing per auth scope so a
/// viewport full of uncached images does not spawn an isolate per row at once.
/// Timestamp each proof when its queued job starts, not when it was enqueued.
Future<Map<String, String>> headersForAsync(String url) {
final nsec = _nsec;
if (nsec == null || nsec.isEmpty || !isRelayMediaUrl(url)) {
return Future.value(const {});
}
final released = Completer<void>();
final result = _signingTail.then((_) async {
try {
return await compute(_mediaHeadersInBackground, (
baseUrl: _baseUrl,
nsec: nsec,
url: url,
now: _now(),
), debugLabel: 'media-get-auth');
} finally {
// A failed worker must propagate to its fetch without blocking later
// requests in the queue.
released.complete();
}
});
_signingTail = released.future;
return result;
}
Map<String, String> headersFor(String url) {
final nsec = _nsec;
if (nsec == null || nsec.isEmpty) return const {};
@@ -132,6 +164,14 @@ class MediaGetAuthService {
}
}
Map<String, String> _mediaHeadersInBackground(
({String baseUrl, String nsec, String url, DateTime now}) request,
) => MediaGetAuthService(
baseUrl: request.baseUrl,
nsec: request.nsec,
now: () => request.now,
).headersFor(request.url);
final mediaGetAuthServiceProvider = Provider<MediaGetAuthService>((ref) {
final config = ref.watch(relayConfigProvider);
return MediaGetAuthService(

View File

@@ -101,6 +101,7 @@ class MediaImageProvider extends ImageProvider<MediaImageProvider> {
}
final uri = Uri.parse(url);
final headers = await auth.headersForAsync(url);
final http.Response response;
try {
final abort = Completer<void>();
@@ -110,7 +111,7 @@ class MediaImageProvider extends ImageProvider<MediaImageProvider> {
'GET',
uri,
abortTrigger: abort.future,
)..headers.addAll(auth.headersFor(url));
)..headers.addAll(headers);
response = await http.Response.fromStream(await client.send(request));
} finally {
deadline.cancel();

View File

@@ -1,5 +1,5 @@
import 'dart:convert';
import 'dart:async';
import 'dart:convert';
import 'package:buzz/shared/relay/media_auth.dart';
import 'package:buzz/shared/relay/media_image.dart';
@@ -70,6 +70,83 @@ void main() {
});
});
group('background media auth', () {
test(
'mints fresh valid server-scoped proofs for concurrent requests',
() async {
final keys = nostr.Keys.generate();
final signedAt = DateTime.utc(2026, 10, 17, 12);
final auth = _auth(nsec: keys.nsec, now: () => signedAt);
final headers = await Future.wait([
auth.headersForAsync(_mediaUrl),
auth.headersForAsync(_mediaUrl),
]);
final events = headers.map((header) {
final encoded = header['Authorization']!.substring('Nostr '.length);
return nostr.Event.fromJson(
utf8.decode(base64Url.decode(base64Url.normalize(encoded))),
);
}).toList();
expect(events[0].sig, isNot(events[1].sig));
for (final event in events) {
expect(event.isValid(), isTrue);
expect(event.pubkey, keys.public);
expect(event.kind, 24242);
expect(event.tags, contains(equals(['t', 'get'])));
expect(event.tags, contains(equals(['server', 'relay.example.com'])));
final expiration = int.parse(
event.tags.singleWhere((tag) => tag.first == 'expiration')[1],
);
expect(expiration, signedAt.millisecondsSinceEpoch ~/ 1000 + 60);
}
},
);
test('keeps the signing identity separate across auth scopes', () async {
final first = nostr.Keys.generate();
final second = nostr.Keys.generate();
for (final keys in [first, second]) {
final header = await _auth(nsec: keys.nsec).headersForAsync(_mediaUrl);
final encoded = header['Authorization']!.substring('Nostr '.length);
final event = nostr.Event.fromJson(
utf8.decode(base64Url.decode(base64Url.normalize(encoded))),
);
expect(event.pubkey, keys.public);
}
});
test('does not expose auth to other origins or invalid paths', () async {
final auth = _auth(nsec: nostr.Keys.generate().nsec);
for (final url in [
'https://elsewhere.com/media/a.png',
'$_relayBase/media-evil/a.png',
'ftp://relay.example.com/media/a.png',
]) {
expect(await auth.headersForAsync(url), isEmpty);
}
expect(await _auth().headersForAsync(_mediaUrl), isEmpty);
expect(await _auth(nsec: 'invalid').headersForAsync(_mediaUrl), isEmpty);
});
test(
'a failed queued job propagates without poisoning later jobs',
() async {
var calls = 0;
final auth = _auth(
nsec: nostr.Keys.generate().nsec,
now: () {
if (calls++ == 0) throw StateError('clock unavailable');
return DateTime.now();
},
);
final failed = auth.headersForAsync(_mediaUrl);
final next = auth.headersForAsync(_mediaUrl);
await expectLater(failed, throwsStateError);
expect(await next, isNotEmpty);
},
);
});
group('MediaImageProvider cache identity', () {
test('equal url + same auth service => equal keys', () {
final auth = _auth(nsec: nostr.Keys.generate().nsec);
@@ -111,6 +188,34 @@ void main() {
});
group('MediaImageProvider fetching', () {
test(
'waits for asynchronous auth before fetching, without synchronous signing',
() async {
var fetches = 0;
final auth = _DeferredMediaAuth();
final client = http_testing.MockClient((request) async {
fetches++;
expect(request.headers['Authorization'], 'Nostr deferred');
return http.Response.bytes(_pngBytes, 200);
});
addTearDown(client.close);
final provider = MediaImageProvider(
url: _mediaUrl,
auth: auth,
client: client,
);
final loaded = expectLater(
_wait(provider.resolve(ImageConfiguration.empty)),
completes,
);
await Future<void>.delayed(Duration.zero);
expect(fetches, 0);
auth.headers.complete({'Authorization': 'Nostr deferred'});
await loaded;
expect(fetches, 1);
},
);
test('resolves one fetch for repeated resolves of the same key', () async {
var fetches = 0;
final client = http_testing.MockClient((request) async {
@@ -242,3 +347,13 @@ Future<Object?> _waitError(ImageStream stream) async {
return e;
}
}
class _DeferredMediaAuth extends MediaGetAuthService {
_DeferredMediaAuth() : super(baseUrl: _relayBase, nsec: null);
final headers = Completer<Map<String, String>>();
@override
Future<Map<String, String>> headersForAsync(String url) => headers.future;
@override
Map<String, String> headersFor(String url) =>
throw StateError('Image layout must not sign synchronously');
}