From a39d813c38d1a8b41a2b72d6786339fe368ee429 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Sun, 18 Oct 2026 18:12:13 -0700 Subject: [PATCH] perf(mobile): reuse prepared media signing identities Signed-off-by: Tom Brow --- mobile/lib/shared/relay/media_auth.dart | 42 ++++++--- .../test/shared/relay/media_image_test.dart | 90 +++++++++++++++++++ 2 files changed, 122 insertions(+), 10 deletions(-) diff --git a/mobile/lib/shared/relay/media_auth.dart b/mobile/lib/shared/relay/media_auth.dart index f3786ea6a0..82c21313e0 100644 --- a/mobile/lib/shared/relay/media_auth.dart +++ b/mobile/lib/shared/relay/media_auth.dart @@ -46,6 +46,10 @@ class MediaGetAuthService { final String? _nsec; final DateTime Function() _now; + // Prepared only after a scoped request. Never shared across service/account + // lifetimes; background requests return it for reuse by the next queued job. + nostr.Keys? _signingIdentity; + Map? _cachedHeaders; DateTime? _refreshAt; // Includes the running job. Bound retained work during rapid history scrolls. @@ -90,12 +94,15 @@ class MediaGetAuthService { final released = Completer(); final result = _signingTail.then((_) async { try { - return await compute(_mediaHeadersInBackground, ( + final signed = await compute(_mediaHeadersInBackground, ( baseUrl: _baseUrl, nsec: nsec, + identity: _signingIdentity, url: url, now: _now(), ), debugLabel: 'media-get-auth'); + _signingIdentity ??= signed.identity; + return signed.headers; } finally { // A failed worker must propagate to its fetch without blocking later // requests in the queue. @@ -192,11 +199,14 @@ class MediaGetAuthService { } nostr.Event _buildGetAuthEvent(String nsec) { - final privkeyHex = nostr.Nip19.decode(payload: nsec).data; + final privkeyHex = + _signingIdentity?.secret ?? nostr.Nip19.decode(payload: nsec).data; if (privkeyHex.isEmpty) { throw Exception('Invalid nsec'); } + final identity = _signingIdentity ??= nostr.Keys(privkeyHex); + final expiration = (_now().millisecondsSinceEpoch ~/ 1000) + _mediaGetAuthLifetimeSeconds; final tags = >[ @@ -210,19 +220,31 @@ class MediaGetAuthService { kind: _mediaGetAuthKind, content: 'Get buzz-media', tags: tags, - secretKey: privkeyHex, + secretKey: identity.secret, + pubkey: identity.public, verify: false, ); } } -Map _mediaHeadersInBackground( - ({String baseUrl, String nsec, String url, DateTime now}) request, -) => MediaGetAuthService( - baseUrl: request.baseUrl, - nsec: request.nsec, - now: () => request.now, -).headersFor(request.url); +({Map headers, nostr.Keys? identity}) _mediaHeadersInBackground( + ({ + String baseUrl, + String nsec, + nostr.Keys? identity, + String url, + DateTime now, + }) + request, +) { + final service = MediaGetAuthService( + baseUrl: request.baseUrl, + nsec: request.nsec, + now: () => request.now, + ).._signingIdentity = request.identity; + final headers = service.headersFor(request.url); + return (headers: headers, identity: service._signingIdentity); +} final mediaGetAuthServiceProvider = Provider((ref) { final config = ref.watch(relayConfigProvider); diff --git a/mobile/test/shared/relay/media_image_test.dart b/mobile/test/shared/relay/media_image_test.dart index 3739333556..1662412ca0 100644 --- a/mobile/test/shared/relay/media_image_test.dart +++ b/mobile/test/shared/relay/media_image_test.dart @@ -3,10 +3,12 @@ import 'dart:convert'; import 'package:buzz/shared/relay/media_auth.dart'; import 'package:buzz/shared/relay/media_image.dart'; +import 'package:buzz/shared/relay/relay_provider.dart'; import 'package:flutter/painting.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart' as http_testing; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; // Minimal valid 1x1 transparent PNG. @@ -102,6 +104,89 @@ void main() { }, ); + test( + 'prepared identity still mints fresh proofs after clock advances', + () async { + final keys = nostr.Keys.generate(); + var now = DateTime.utc(2026, 10, 18, 12); + final auth = _auth(nsec: keys.nsec, now: () => now); + final first = await auth.headersForAsync(_mediaUrl); + now = now.add(const Duration(minutes: 2)); + final next = await auth.headersForAsync(_mediaUrl); + final encoded = next['Authorization']!.substring('Nostr '.length); + final event = nostr.Event.fromJson( + utf8.decode(base64Url.decode(base64Url.normalize(encoded))), + ); + expect(event.pubkey, keys.public); + expect( + event.tags, + contains( + equals([ + 'expiration', + '${now.millisecondsSinceEpoch ~/ 1000 + 60}', + ]), + ), + ); + expect(next, isNot(equals(first))); + expect(auth.headersFor('https://elsewhere.com/media/a'), isEmpty); + expect( + await auth.headersForAsync('https://elsewhere.com/media/a'), + isEmpty, + ); + }, + ); + + test( + 'provider replacement isolates warmed and queued identities', + () async { + final first = nostr.Keys.generate(); + final second = nostr.Keys.generate(); + final container = ProviderContainer( + overrides: [relayConfigProvider.overrideWith(_TestRelayConfig.new)], + ); + addTearDown(container.dispose); + final config = container.read(relayConfigProvider.notifier); + config.update(baseUrl: _relayBase, nsec: first.nsec); + final old = container.read(mediaGetAuthServiceProvider); + await old.headersForAsync(_mediaUrl); + final queued = old.headersForAsync(_mediaUrl); + config.update(baseUrl: _relayBase, nsec: second.nsec); + final replacement = container.read(mediaGetAuthServiceProvider); + expect(identical(old, replacement), isFalse); + final results = await Future.wait([ + queued, + replacement.headersForAsync(_mediaUrl), + replacement.headersForAsync(_mediaUrl), + ]); + for (var i = 0; i < results.length; i++) { + final encoded = results[i]['Authorization']!.substring( + 'Nostr '.length, + ); + final event = nostr.Event.fromJson( + utf8.decode(base64Url.decode(base64Url.normalize(encoded))), + ); + expect(event.pubkey, i == 0 ? first.public : second.public); + } + config.update( + baseUrl: 'https://replacement.example', + nsec: second.nsec, + ); + final switched = container.read(mediaGetAuthServiceProvider); + expect(await switched.headersForAsync(_mediaUrl), isEmpty); + expect( + await switched.headersForAsync('https://replacement.example/media/a'), + isNotEmpty, + ); + config.update(baseUrl: _relayBase, nsec: null); + expect( + await container + .read(mediaGetAuthServiceProvider) + .headersForAsync(_mediaUrl), + isEmpty, + ); + }, + ); + test('bounds queued signing and admits requests after it drains', () async { final auth = _auth(nsec: nostr.Keys.generate().nsec); // Enqueue in one turn, before a worker can finish. Record every failure @@ -399,3 +484,8 @@ class _DeferredMediaAuth extends MediaGetAuthService { Map headersFor(String url) => throw StateError('Image layout must not sign synchronously'); } + +class _TestRelayConfig extends RelayConfigNotifier { + @override + RelayConfig build() => const RelayConfig(baseUrl: _relayBase); +}