diff --git a/mobile/lib/features/channels/message_content/video_preview.dart b/mobile/lib/features/channels/message_content/video_preview.dart index b9fb4f33d5..2da8d37f96 100644 --- a/mobile/lib/features/channels/message_content/video_preview.dart +++ b/mobile/lib/features/channels/message_content/video_preview.dart @@ -30,7 +30,8 @@ final videoPreviewFrameLoaderProvider = Provider(( 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 { diff --git a/mobile/lib/shared/relay/media_auth.dart b/mobile/lib/shared/relay/media_auth.dart index 957984a15e..924634fa1d 100644 --- a/mobile/lib/shared/relay/media_auth.dart +++ b/mobile/lib/shared/relay/media_auth.dart @@ -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? _cachedHeaders; DateTime? _refreshAt; + Future _signingTail = Future.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> headersForAsync(String url) { + final nsec = _nsec; + if (nsec == null || nsec.isEmpty || !isRelayMediaUrl(url)) { + return Future.value(const {}); + } + final released = Completer(); + 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 headersFor(String url) { final nsec = _nsec; if (nsec == null || nsec.isEmpty) return const {}; @@ -132,6 +164,14 @@ class MediaGetAuthService { } } +Map _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((ref) { final config = ref.watch(relayConfigProvider); return MediaGetAuthService( diff --git a/mobile/lib/shared/relay/media_image.dart b/mobile/lib/shared/relay/media_image.dart index 857fbcd663..cb2480f58b 100644 --- a/mobile/lib/shared/relay/media_image.dart +++ b/mobile/lib/shared/relay/media_image.dart @@ -101,6 +101,7 @@ class MediaImageProvider extends ImageProvider { } final uri = Uri.parse(url); + final headers = await auth.headersForAsync(url); final http.Response response; try { final abort = Completer(); @@ -110,7 +111,7 @@ class MediaImageProvider extends ImageProvider { '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(); diff --git a/mobile/test/shared/relay/media_image_test.dart b/mobile/test/shared/relay/media_image_test.dart index e3626cf892..b3d8b7a5bf 100644 --- a/mobile/test/shared/relay/media_image_test.dart +++ b/mobile/test/shared/relay/media_image_test.dart @@ -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.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 _waitError(ImageStream stream) async { return e; } } + +class _DeferredMediaAuth extends MediaGetAuthService { + _DeferredMediaAuth() : super(baseUrl: _relayBase, nsec: null); + final headers = Completer>(); + @override + Future> headersForAsync(String url) => headers.future; + @override + Map headersFor(String url) => + throw StateError('Image layout must not sign synchronously'); +}