Files
buzz/mobile/lib/shared/push/push_lease_revocation_outbox.dart
Tom Brow 97ede701c0 fix(mobile): send hosted relay assertion from native media and tombstone paths (#9069)
Follow-up to https://github.com/block/buzz/pull/9056, from an audit for
other places that miss the hosted relay assertion. Hosted and
organization relays require `Nostr-Federated-Identity` on every
non-exempt HTTP request and WebSocket upgrade. The Dart
`HostedRelayHttpClient` adds it, but anything that downloads or connects
outside that client has to add it itself. The notification service
extension and the iOS emoji picker both missed it, and these places do
too:

- **Voice notes** (`just_audio`, mainly Android) and **video preview
first frames** fetch media natively with only the Blossom
`Authorization` header. Both now use `nativeMediaHeadersProvider` from
https://github.com/block/buzz/pull/9056.
- **Push-lease tombstones**: removing a community, and the durable
revocation outbox, open a one-off relay socket with no headers. A hosted
relay refuses the upgrade, so the lease is never revoked and the outbox
keeps retrying. Both now ask `HostedRelayAuth` for that community's own
assertion, because the community being removed may not be the active
session.
- **Notification avatars** (iOS notification service extension):
relay-hosted sender avatars were fetched with Blossom auth only. The
loader now takes the same `BuzzRelayAssertionIssuer` the resolver uses
for `/query`. Signed requests already refuse redirects.

The iOS Me-chat agent picker also fetched avatars with no headers, but
https://github.com/block/buzz/pull/9079 removed that native screen, so
this PR no longer touches it.

### Validation

- Added tests, each checked to fail when the code it guards is reverted:
- A queued push-lease revocation and a community-removal tombstone both
send `Nostr-Federated-Identity` on the WebSocket upgrade, as background
requests. Runs against a local relay socket that handles NIP-42 auth.
- A notification avatar on an organization relay is downloaded with the
issued assertion (BuzzPushKit).
- Issuing for a second community keeps the first one's cached assertion.
A background request records no failure, never backs up the key, and its
failure never reaches the active community's reconnect waiting on it.
- Background issuance never binds an unbound account to its key,
bypasses the shared assertion cache (each attempt gets a fresh
assertion, and foreground use never inherits one that skipped the key
backup), and its failure is never handed to a foreground caller waiting
on it. When it can't get an assertion it sends none, so a non-enforcing
relay still accepts the tombstone.
- A notification avatar whose organization session is replaced
mid-download is refused and dropped from the cache (BuzzPushKit).
- Voice notes and posterless video previews renew an expired assertion
before the native fetch, and fall back to cached headers if renewal
fails. A voice-note attachment journey binds the production provider.
- Cached reads never present a signed-out or replaced account's
assertion.
- `flutter analyze` is clean. `swift test` passes for the BuzzPushKit
avatar loader tests, and `flutter build ios --simulator --debug`
succeeds.
- Full mobile suite (`just mobile-check` and `just mobile-test`) passes
at 2c84940832.
- Automated full-app run on an iOS simulator (debug build of this PR
before it was rebuilt on main, signed in to the internal Builderlab
community with two more communities saved). Passed: launch and channel
list, image attachments and avatars in a channel, New chat sheet kept
open for about 80 seconds before the agent list was opened (all agent
avatars loaded, so headers are requested per download), dismiss and
reopen, background and resume, switching to a second community (its icon
and avatars loaded), and removing a community. The native New chat sheet
is unreachable in the normal flow on this branch (a new chat opens the
Flutter draft page; the native sheet only opens when resuming an
unfinished chat), so that check ran with a temporary local bypass that
was reverted. Limits: none of these communities requires the hosted
relay assertion in this build, so the run shows the Swift-to-Dart header
bridge, the per-relay cache and the tombstone path cause no regression,
not that the assertion is sent or accepted. No voice note, video preview
or deleted message was reachable. Screenshots are omitted because they
show internal channel content.
- Limitations: no end-to-end run against a hosted relay. Simulator
sign-in to a hosted relay needs SSO, so a hosted-relay run needs a
device build.

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
2026-11-05 12:32:18 -08:00

583 lines
19 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:buzz/shared/crypto/event_signing.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import '../storage/secure_storage.dart';
import '../community/community.dart';
import '../hosted/hosted_relay_auth.dart';
import '../relay/relay_provider.dart';
import '../relay/signed_event_relay.dart';
import 'dev_push_lease.dart';
import 'push_bridge.dart';
const _maxSafeJsonInteger = 9007199254740991;
const _baseRetryDelay = Duration(seconds: 30);
const _maximumRetryDelay = Duration(hours: 6);
const _lowercaseHex64Pattern = r'^[0-9a-f]{64}$';
const _installationIdPattern = r'^[0-9a-f]{32}$';
typedef BuzzPushLeaseRevocationPublisher =
Future<void> Function(BuzzPushLeaseRevocationRecord record);
typedef BuzzPushLeaseRevocationClock = DateTime Function();
typedef BuzzPushLeaseRevocationJitter = double Function();
typedef BuzzPushLeaseRevocationErrorReporter =
void Function(Object error, StackTrace stackTrace);
typedef BuzzPushLeaseRevocationWakeScheduler =
void Function() Function(Duration delay, void Function() wake);
/// Durable state for retracting one precise NIP-PL lease address.
///
/// The signing key is intentionally retained in secure storage only until the
/// relay accepts the tombstone or the old endpoint grant is known to expire.
@immutable
class BuzzPushLeaseRevocationRecord {
final String relayUrl;
final String relayOrigin;
final String memberPubkey;
final String nsec;
final String installationId;
/// Generation reserved for the next relay attempt.
final int generation;
/// Null when the grant is unavailable and its expiry cannot be established.
final int? expiresAt;
final int attemptCount;
final int nextAttemptAt;
BuzzPushLeaseRevocationRecord({
required this.relayUrl,
required this.relayOrigin,
required this.memberPubkey,
required this.nsec,
required this.installationId,
required this.generation,
required this.expiresAt,
required this.attemptCount,
required this.nextAttemptAt,
}) {
if (canonicalBuzzPushRelayOrigin(relayUrl) != relayOrigin) {
throw const FormatException(
'Push revocation relay URL and origin do not match.',
);
}
if (!RegExp(_lowercaseHex64Pattern).hasMatch(memberPubkey)) {
throw const FormatException(
'Push revocation member pubkey must be exact lowercase hex.',
);
}
final decoded = nostr.Nip19.decode(payload: nsec);
if (decoded.prefix != nostr.Nip19Prefix.nsec ||
decoded.data.length != 64 ||
derivePublicKeyHex(decoded.data) != memberPubkey) {
throw const FormatException(
'Push revocation signing key does not match its member pubkey.',
);
}
if (!RegExp(_installationIdPattern).hasMatch(installationId)) {
throw const FormatException(
'Push revocation installation id must be exact lowercase hex.',
);
}
if (generation <= 0 || generation > _maxSafeJsonInteger) {
throw const FormatException('Push revocation generation is invalid.');
}
if ((expiresAt != null && expiresAt! <= 0) ||
attemptCount < 0 ||
nextAttemptAt < 0) {
throw const FormatException('Push revocation retry state is invalid.');
}
}
String get leaseAddress => '$memberPubkey|$relayOrigin|$installationId';
BuzzPushLeaseRevocationRecord copyWith({
int? generation,
int? expiresAt,
int? attemptCount,
int? nextAttemptAt,
}) => BuzzPushLeaseRevocationRecord(
relayUrl: relayUrl,
relayOrigin: relayOrigin,
memberPubkey: memberPubkey,
nsec: nsec,
installationId: installationId,
generation: generation ?? this.generation,
expiresAt: expiresAt ?? this.expiresAt,
attemptCount: attemptCount ?? this.attemptCount,
nextAttemptAt: nextAttemptAt ?? this.nextAttemptAt,
);
Map<String, dynamic> toJson() => {
'version': 1,
'relayUrl': relayUrl,
'relayOrigin': relayOrigin,
'memberPubkey': memberPubkey,
'nsec': nsec,
'installationId': installationId,
'generation': generation,
'expiresAt': expiresAt,
'attemptCount': attemptCount,
'nextAttemptAt': nextAttemptAt,
};
factory BuzzPushLeaseRevocationRecord.fromJson(Map<String, dynamic> json) {
const keys = {
'version',
'relayUrl',
'relayOrigin',
'memberPubkey',
'nsec',
'installationId',
'generation',
'expiresAt',
'attemptCount',
'nextAttemptAt',
};
if (json.keys.toSet().difference(keys).isNotEmpty ||
keys.difference(json.keys.toSet()).isNotEmpty ||
json['version'] != 1 ||
json['relayUrl'] is! String ||
json['relayOrigin'] is! String ||
json['memberPubkey'] is! String ||
json['nsec'] is! String ||
json['installationId'] is! String ||
json['generation'] is! int ||
(json['expiresAt'] != null && json['expiresAt'] is! int) ||
json['attemptCount'] is! int ||
json['nextAttemptAt'] is! int) {
throw const FormatException('Invalid push revocation record.');
}
return BuzzPushLeaseRevocationRecord(
relayUrl: json['relayUrl'] as String,
relayOrigin: json['relayOrigin'] as String,
memberPubkey: json['memberPubkey'] as String,
nsec: json['nsec'] as String,
installationId: json['installationId'] as String,
generation: json['generation'] as int,
expiresAt: json['expiresAt'] as int?,
attemptCount: json['attemptCount'] as int,
nextAttemptAt: json['nextAttemptAt'] as int,
);
}
}
class BuzzPushLeaseRevocationStorage {
static const _key = 'buzz_push_lease_revocations_v1';
final FlutterSecureStorage _secure;
BuzzPushLeaseRevocationStorage({FlutterSecureStorage? secure})
: _secure = secure ?? communitySecureStorage;
Future<List<BuzzPushLeaseRevocationRecord>> loadAll() async {
final raw = await _secure.read(key: _key);
if (raw == null) return <BuzzPushLeaseRevocationRecord>[];
final decoded = jsonDecode(raw);
if (decoded is! List<dynamic>) {
throw const FormatException('Push revocation outbox must be a list.');
}
final records = [
for (final value in decoded)
BuzzPushLeaseRevocationRecord.fromJson(
Map<String, dynamic>.from(value as Map),
),
];
final addresses = records.map((record) => record.leaseAddress).toSet();
if (addresses.length != records.length) {
throw const FormatException(
'Push revocation outbox contains duplicate lease addresses.',
);
}
return records;
}
Future<void> replaceAll(
Iterable<BuzzPushLeaseRevocationRecord> records,
) async {
final values = records.toList();
final addresses = values.map((record) => record.leaseAddress).toSet();
if (addresses.length != values.length) {
throw const FormatException(
'Push revocation outbox contains duplicate lease addresses.',
);
}
if (values.isEmpty) {
await _secure.delete(key: _key);
return;
}
await _secure.write(
key: _key,
value: jsonEncode([for (final record in values) record.toJson()]),
);
}
}
/// A durable, single-flight retry coordinator for removed-community leases.
///
/// Every attempt reserves its successor generation and retry time before
/// network I/O. A process death during an ambiguous publication therefore
/// cannot cause an immediate replay or reuse a generation the relay may have
/// accepted.
class BuzzPushLeaseRevocationOutbox {
BuzzPushLeaseRevocationOutbox({
required this.storage,
required this.publisher,
this.now = DateTime.now,
BuzzPushLeaseRevocationJitter? jitter,
this.reportError = reportPushLeaseCleanupError,
BuzzPushLeaseRevocationWakeScheduler? scheduleWake,
}) : jitter = jitter ?? Random.secure().nextDouble,
scheduleWake = scheduleWake ?? _scheduleTimer;
final BuzzPushLeaseRevocationStorage storage;
final BuzzPushLeaseRevocationPublisher publisher;
final BuzzPushLeaseRevocationClock now;
final BuzzPushLeaseRevocationJitter jitter;
final BuzzPushLeaseRevocationErrorReporter reportError;
final BuzzPushLeaseRevocationWakeScheduler scheduleWake;
Future<void> _storageTail = Future.value();
Future<void>? _drain;
int _wakeGeneration = 0;
void Function()? _cancelWake;
bool _started = false;
bool _disposed = false;
Future<T> _serialize<T>(Future<T> Function() operation) {
final result = _storageTail.then((_) => operation());
_storageTail = result.then<void>(
(_) {},
onError: (Object _, StackTrace _) {},
);
return result;
}
Future<void> enqueue(BuzzPushLeaseRevocationRecord record) async {
await _serialize(() async {
final records = await storage.loadAll();
final index = records.indexWhere(
(candidate) => candidate.leaseAddress == record.leaseAddress,
);
if (index >= 0) {
if (record.generation <= records[index].generation) return;
records[index] = record;
} else {
records.add(record);
}
await storage.replaceAll(records);
});
if (_started) _scheduleNextWake();
}
Future<bool> enqueueCommunity(
Community community, {
Future<List<BuzzPushEndpointGrant>> Function()? readGrants,
}) async {
final state = community.pushSubscriptionState;
final highestGeneration =
state.generationCursor ?? state.acceptedGeneration;
if (highestGeneration == null ||
(!community.pushNotificationsEnabled &&
state.pendingTombstoneGeneration == null)) {
return false;
}
final nsec = community.nsec;
if (nsec == null || nsec.isEmpty) {
throw StateError(
'Push lease revocation requires a community signing key.',
);
}
final decoded = nostr.Nip19.decode(payload: nsec);
final memberPubkey = community.pubkey ?? derivePublicKeyHex(decoded.data);
final relayUrl = canonicalBuzzPushRelayHttpUrl(community.relayUrl);
final relayOrigin = canonicalBuzzPushRelayOrigin(relayUrl);
final grants = await (readGrants ?? readBuzzPushEndpointGrants)();
final matching = grants
.where((grant) => grant.relayOrigin == relayOrigin)
.toList();
if (matching.length > 1 ||
(matching.isEmpty && community.pushLeaseInstallationId == null)) {
throw StateError(
'Expected exactly one endpoint grant for push lease revocation.',
);
}
final currentSeconds = now().millisecondsSinceEpoch ~/ 1000;
final grant = matching.singleOrNull;
if (grant != null && grant.expiresAt <= currentSeconds) return false;
if (highestGeneration + 2 > _maxSafeJsonInteger) {
throw StateError('Push lease generation is exhausted.');
}
await enqueue(
BuzzPushLeaseRevocationRecord(
relayUrl: relayUrl,
relayOrigin: relayOrigin,
memberPubkey: memberPubkey,
nsec: nsec,
installationId:
community.pushLeaseInstallationId ?? grant!.installationId,
generation: highestGeneration + 2,
expiresAt: grant?.expiresAt,
attemptCount: 0,
nextAttemptAt: currentSeconds,
),
);
return true;
}
Future<void> start() async {
if (_disposed) throw StateError('Push revocation outbox is disposed.');
if (_started) return trigger();
_started = true;
await trigger();
}
Future<void> trigger() {
if (_disposed) return Future.value();
final active = _drain;
if (active != null) return active;
final drain = _drainDue();
_drain = drain;
void finish() {
if (identical(_drain, drain)) _drain = null;
if (!_disposed) _scheduleNextWake();
}
drain.then<void>((_) => finish(), onError: (_, _) => finish());
return drain;
}
Future<void> _drainDue() async {
while (!_disposed) {
final record = await _reserveNextDueAttempt();
if (record == null) return;
try {
await publisher(record);
await _removeAccepted(record);
pushLeaseCleanupError.value = null;
} catch (error, stackTrace) {
reportError(error, stackTrace);
}
}
}
Future<BuzzPushLeaseRevocationRecord?> _reserveNextDueAttempt() => _serialize(
() async {
final currentSeconds = now().millisecondsSinceEpoch ~/ 1000;
final records = await storage.loadAll();
final active = records
.where(
(record) =>
record.expiresAt == null || record.expiresAt! > currentSeconds,
)
.toList();
final due =
active
.where((record) => record.nextAttemptAt <= currentSeconds)
.toList()
..sort((left, right) {
final schedule = left.nextAttemptAt.compareTo(
right.nextAttemptAt,
);
return schedule != 0
? schedule
: left.leaseAddress.compareTo(right.leaseAddress);
});
if (due.isEmpty) {
if (active.length != records.length) {
await storage.replaceAll(active);
}
return null;
}
final record = due.first;
if (record.generation >= _maxSafeJsonInteger) {
throw StateError('Push lease generation is exhausted.');
}
final reserved = record.copyWith(
generation: record.generation + 1,
attemptCount: record.attemptCount + 1,
nextAttemptAt: currentSeconds + _retryDelaySeconds(record.attemptCount),
);
final index = active.indexWhere(
(candidate) => candidate.leaseAddress == record.leaseAddress,
);
if (index < 0) {
throw StateError('Reserved push revocation record disappeared.');
}
active[index] = reserved;
await storage.replaceAll(active);
return record;
},
);
int _retryDelaySeconds(int priorAttempts) {
final exponent = min(priorAttempts, 20);
final factor = 1 << exponent;
final maximumSeconds = min(
_maximumRetryDelay.inSeconds,
_baseRetryDelay.inSeconds * factor,
);
final half = maximumSeconds ~/ 2;
return half + (jitter() * (maximumSeconds - half)).floor();
}
Future<void> _removeAccepted(BuzzPushLeaseRevocationRecord attempted) =>
_serialize(() async {
final records = await storage.loadAll();
records.removeWhere(
(record) =>
record.leaseAddress == attempted.leaseAddress &&
record.generation == attempted.generation + 1,
);
await storage.replaceAll(records);
});
void _scheduleNextWake() {
if (!_started || _disposed) return;
final generation = ++_wakeGeneration;
_cancelWake?.call();
_cancelWake = null;
unawaited(
_serialize(() async {
final currentSeconds = now().millisecondsSinceEpoch ~/ 1000;
final records = await storage.loadAll();
final active = records
.where(
(record) =>
record.expiresAt == null ||
record.expiresAt! > currentSeconds,
)
.toList();
if (active.length != records.length) {
await storage.replaceAll(active);
}
if (active.isEmpty || generation != _wakeGeneration || _disposed) {
return;
}
final wakeAt = active
.map(
(record) => min(
record.nextAttemptAt,
record.expiresAt ?? record.nextAttemptAt,
),
)
.reduce(min);
final delay = Duration(seconds: max(0, wakeAt - currentSeconds));
_cancelWake = scheduleWake(delay, () {
if (generation != _wakeGeneration || _disposed) return;
_cancelWake = null;
unawaited(trigger());
});
}),
);
}
void dispose() {
_disposed = true;
_wakeGeneration += 1;
_cancelWake?.call();
_cancelWake = null;
}
}
void Function() _scheduleTimer(Duration delay, void Function() wake) {
final timer = Timer(delay, wake);
return timer.cancel;
}
String canonicalBuzzPushRelayOrigin(String relayUrl) {
final uri = _buzzPushRelayUri(relayUrl);
final scheme = switch (uri.scheme) {
'https' || 'wss' => 'wss',
'http' || 'ws' => 'ws',
_ => throw StateError('Validated relay URL has an unsupported scheme.'),
};
return '$scheme://${uri.authority}';
}
String canonicalBuzzPushRelayHttpUrl(String relayUrl) {
final uri = _buzzPushRelayUri(relayUrl);
final scheme = switch (uri.scheme) {
'https' || 'wss' => 'https',
'http' || 'ws' => 'http',
_ => throw StateError('Validated relay URL has an unsupported scheme.'),
};
return uri.replace(scheme: scheme, path: '/').toString();
}
Uri _buzzPushRelayUri(String relayUrl) {
final uri = Uri.tryParse(relayUrl);
if (uri == null ||
!const {'http', 'https', 'ws', 'wss'}.contains(uri.scheme) ||
uri.host.isEmpty ||
uri.userInfo.isNotEmpty ||
(uri.path.isNotEmpty && uri.path != '/') ||
uri.hasQuery ||
uri.hasFragment) {
throw FormatException('Invalid relay URL for push revocation: $relayUrl');
}
return uri;
}
Future<void> publishBuzzPushLeaseRevocation(
BuzzPushLeaseRevocationRecord record, {
required HostedRelayAuth relayAuth,
}) async {
final descriptor = await fetchBuzzPushLeaseDescriptor(record.relayUrl);
if (descriptor.origin != record.relayOrigin) {
throw StateError('Relay push origin changed while revocation was pending.');
}
final uri = Uri.parse(record.relayUrl);
final wsUrl = uri
.replace(scheme: uri.scheme == 'https' ? 'wss' : 'ws')
.toString();
await publishBuzzPushLeaseTombstone(
descriptor: descriptor,
installationId: record.installationId,
generation: record.generation,
nsec: record.nsec,
memberPubkey: record.memberPubkey,
submit:
({required kind, required content, required tags, createdAt}) async =>
submitSignedEventOnce(
wsUrl: wsUrl,
// A hosted relay refuses the socket upgrade without an assertion.
headers: await relayAuth.headers(
RelayConfig(baseUrl: record.relayUrl, nsec: record.nsec),
background: true,
),
nsec: record.nsec,
kind: kind,
content: content,
tags: tags,
createdAt: createdAt,
),
);
}
final buzzPushLeaseRevocationStorageProvider =
Provider<BuzzPushLeaseRevocationStorage>(
(ref) => BuzzPushLeaseRevocationStorage(),
);
final buzzPushLeaseRevocationOutboxProvider =
Provider<BuzzPushLeaseRevocationOutbox>((ref) {
final outbox = BuzzPushLeaseRevocationOutbox(
storage: ref.read(buzzPushLeaseRevocationStorageProvider),
publisher: (record) => publishBuzzPushLeaseRevocation(
record,
relayAuth: ref.read(hostedRelayAuthProvider),
),
);
ref.onDispose(outbox.dispose);
return outbox;
});