perf(mobile): reuse prepared media signing identities

Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-10-18 18:12:13 -07:00
parent 6c5102ac40
commit a39d813c38
2 changed files with 122 additions and 10 deletions

View File

@@ -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<String, String>? _cachedHeaders;
DateTime? _refreshAt;
// Includes the running job. Bound retained work during rapid history scrolls.
@@ -90,12 +94,15 @@ class MediaGetAuthService {
final released = Completer<void>();
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 = <List<String>>[
@@ -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<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);
({Map<String, String> 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<MediaGetAuthService>((ref) {
final config = ref.watch(relayConfigProvider);

View File

@@ -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<String, String> headersFor(String url) =>
throw StateError('Image layout must not sign synchronously');
}
class _TestRelayConfig extends RelayConfigNotifier {
@override
RelayConfig build() => const RelayConfig(baseUrl: _relayBase);
}