Files
buzz/mobile/lib/shared/relay/media_auth.dart
Wes 91b1cc4761 perf(mobile): reuse the derived pubkey when signing auth events (#8460)
## Summary

Each relay `/query` signs a NIP-98 event, and each relay media image GET
signs a Blossom auth event. Both run on the UI isolate. `nostr`'s
`Event.from` derives the pubkey from the secret key on every call, which
is about a third of the cost of a signature.

Both call sites now pass the pubkey that `myPubkeyProvider` already
caches for the active key. The provider is derived from the same
`relayConfigProvider` state as the key, so the two always match.

This is the quick win. Moving signing to native secp256k1 or a worker
isolate would remove the rest of the cost, and that would be a separate
change.

## Before and after

AOT-compiled benchmark on Apple silicon, `nostr` 2.0.0 `Event.from`,
three runs of 50 signatures each:

| | ms per signature |
|---|---|
| `main` (pubkey derived) | 8.8 to 9.6 |
| this PR (pubkey passed) | 5.9 to 6.4 |

A scroll that shows 20 uncached relay-media images now spends about 60
ms less on the UI thread.

## Test

There are no new tests. The existing `queryRelay` test runs through the
real `relayConfigProvider` and `myPubkeyProvider`, and asserts the
signed event's pubkey for one identity. It does not verify the signature
or switch identities, and it would still pass with this optimization
removed. The media tests build the service directly, so they only cover
the fallback where the pubkey is derived.

Review traced the pairing instead. Both the key and the pubkey come from
`relayConfigProvider`. `_postForEvents` reads both and signs with no
await in between, and the media provider watches both. So the key and
pubkey change together on community switch and sign-out.

## Validation

- `flutter analyze`
- Full mobile suite: 3,534 passed.

**Human test:** Wes ran #8456, #8460 and #8462 together on the simulator
with his real account and confirmed they work.

buzz-review-completed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:10:14 -06:00

178 lines
6.0 KiB
Dart

import 'dart:convert';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'relay_provider.dart';
const _mediaGetAuthKind = 24242;
const _mediaGetAuthLifetimeSeconds = 60;
/// Re-sign this long before the cached auth event expires, so an in-flight
/// request signed just before the boundary still lands well within validity.
/// With a 60-second lifetime, the margin equals the lifetime: each request
/// mints a fresh token (mint-per-request pattern for NIP-FI compliance).
const _mediaGetAuthRefreshMarginSeconds = 60;
/// Builds BUD-01 Blossom `t=get` auth headers for relay-host media URLs.
///
/// Returns an empty map for non-relay URLs or when no signing key is available,
/// so callers can safely use this on arbitrary profile/custom-emoji URLs without
/// leaking Buzz credentials to third-party hosts.
///
/// With a 60-second proof lifetime the refresh margin equals the lifetime, so
/// `_refreshAt == signedAt` and the cache never hits: every call mints a fresh
/// proof (mint-per-request pattern). This is intentional for NIP-FI compliance
/// — caching a 60-second token is staleness-prone and provides no meaningful
/// reduction in signing work. The service itself is rebuilt whenever the relay
/// config — base URL or signing identity — changes, via
/// [mediaGetAuthServiceProvider].
class MediaGetAuthService {
final String _baseUrl;
final String? _nsec;
final String? _pubkey;
final DateTime Function() _now;
Map<String, String>? _cachedHeaders;
DateTime? _refreshAt;
MediaGetAuthService({
required String baseUrl,
required String? nsec,
String? pubkey,
DateTime Function()? now,
}) : _baseUrl = baseUrl,
_nsec = nsec,
_pubkey = pubkey,
_now = now ?? DateTime.now;
bool isRelayMediaUrl(String url) {
final uri = Uri.tryParse(url);
final relayUri = Uri.tryParse(_baseUrl);
if (uri == null || relayUri == null) return false;
return _isRelayMediaUrl(uri, relayUri);
}
Map<String, String> headersFor(String url) {
final nsec = _nsec;
if (nsec == null || nsec.isEmpty) return const {};
if (!isRelayMediaUrl(url)) return const {};
final cached = _cachedHeaders;
final refreshAt = _refreshAt;
if (cached != null && refreshAt != null && _now().isBefore(refreshAt)) {
return cached;
}
try {
final signedAt = _now();
final authEvent = _buildGetAuthEvent(nsec);
final encoded = base64Url
.encode(utf8.encode(authEvent.toJson()))
.replaceAll('=', '');
final headers = Map<String, String>.unmodifiable({
'Authorization': 'Nostr $encoded',
});
_cachedHeaders = headers;
_refreshAt = signedAt.add(
const Duration(
seconds:
_mediaGetAuthLifetimeSeconds - _mediaGetAuthRefreshMarginSeconds,
),
);
return headers;
} catch (_) {
// Read auth is best-effort: while the relay rollout flag is off, an
// unsigned fetch still works. Once the flag is on, this request will 403
// instead of crashing the widget tree because local key material is bad.
return const {};
}
}
bool _isRelayMediaUrl(Uri uri, Uri relayUri) {
if (uri.scheme != 'http' && uri.scheme != 'https') return false;
if (uri.host.isEmpty || relayUri.host.isEmpty) return false;
// Extract the URL's origin and path. Query strings are ignored for media
// host/path detection, matching the fetch target shape used by descriptors.
final base = '${uri.scheme}://${uri.authority}';
final mediaAuthority = extractServerAuthority(base);
final relayAuthority = extractServerAuthority(_baseUrl);
if (mediaAuthority == null || relayAuthority == null) return false;
if (mediaAuthority.toLowerCase() != relayAuthority.toLowerCase()) {
return false;
}
return uri.path.startsWith('/media/');
}
nostr.Event _buildGetAuthEvent(String nsec) {
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
if (privkeyHex.isEmpty) {
throw Exception('Invalid nsec');
}
final expiration =
(_now().millisecondsSinceEpoch ~/ 1000) + _mediaGetAuthLifetimeSeconds;
final tags = <List<String>>[
['t', 'get'],
['expiration', '$expiration'],
if (extractServerAuthority(_baseUrl) case final authority?)
['server', authority],
];
return nostr.Event.from(
kind: _mediaGetAuthKind,
content: 'Get buzz-media',
tags: tags,
secretKey: privkeyHex,
// Deriving the pubkey costs about a third of a signature, per image.
pubkey: _pubkey,
verify: false,
);
}
}
final mediaGetAuthServiceProvider = Provider<MediaGetAuthService>((ref) {
final config = ref.watch(relayConfigProvider);
return MediaGetAuthService(
baseUrl: config.baseUrl,
nsec: config.nsec,
pubkey: ref.watch(myPubkeyProvider),
);
});
Map<String, String> mediaGetHeadersFor(WidgetRef ref, String url) {
return ref.read(mediaGetAuthServiceProvider).headersFor(url);
}
Map<String, String> mediaGetHeadersForContext(
BuildContext context,
String url,
) {
final container = ProviderScope.containerOf(context, listen: false);
return container.read(mediaGetAuthServiceProvider).headersFor(url);
}
String? extractServerAuthority(String baseUrl) {
final uri = Uri.parse(baseUrl);
if (uri.host.isEmpty) return null;
final host = uri.host.contains(':') ? '[${uri.host}]' : uri.host;
final port = uri.hasPort ? uri.port : null;
final authority = port == null ? host : '$host:$port';
return _normalizeAuthority(authority);
}
String _normalizeAuthority(String authority) {
var normalized = authority.trim().toLowerCase();
if (normalized.endsWith('.')) {
normalized = normalized.substring(0, normalized.length - 1);
}
if (normalized.endsWith(':443')) {
return normalized.substring(0, normalized.length - ':443'.length);
}
if (normalized.endsWith(':80')) {
return normalized.substring(0, normalized.length - ':80'.length);
}
return normalized;
}