mirror of
https://github.com/block/buzz.git
synced 2026-11-12 04:12:21 +00:00
The Block tenant links a member's existing key to their account the first time it issues them a relay assertion ([#8700](https://github.com/block/buzz/pull/8700)). Mobile never backed that key up. Desktop FI builds back up to the tenant, but on mobile a Block key that lives on just one phone can't be recovered once Block turns backups on. Mobile now backs up the organization relay's key to the organization account: - After every organization assertion, it reads `/v1/buzz/nsec-backups/status`. If the key is already listed, there's nothing to do. - If the bound key isn't listed, it enrolls, seals with HPKE and uploads, using the existing `HostedBackupService`. That service now uses its account client's own origin, so organization and hosted backups never share a pending envelope. Hosted storage keys and receipts don't change. - If the upload's outcome is unknown, the saved envelope is replayed exactly at the next assertion. - A tenant without backups answers 404 (Block production today). That, and other definitive refusals, stop attempts for that account and key until the next launch. Any other failure is retried at a later assertion, after a delay that doubles from 1 minute to at most 1 hour. Assertions renew while the app runs, so retries continue until the tenant lists the key. - Concurrent assertions share one attempt. - iOS 16 can't seal, so it sends nothing. Backup failures never affect the relay connection. There's no UI. As in mobile's hosted onboarding, the backup happens automatically. Desktop asks for an explicit backup choice first, so whether mobile should too is a product call. ### Validation Added tests (`organization_key_backup_test.dart`), which run through real `HostedRelayAuth` assertion issuance with only HTTP faked: - An uncertain upload is replayed with the same envelope at the next assertion, then nothing more is sent in that launch. - A key that's already listed sends no upload and drops a leftover envelope. - A tenant without backups is asked once per launch. - A device that can't seal sends nothing. - A failed status read is retried after the delay, not at the immediate re-assertion. A key the tenant bound to someone else is never uploaded, and its checks keep going at growing intervals. - Two concurrent assertions make one attempt. - A failing seal-capability check sends nothing. Removing the assertion hook fails 4 of the 6 tests. Removing the shared attempt or the retry delay fails its test. Full `mobile-check` and `mobile-test` pass. Device (iPhone 12 mini, release build) against bb-block staging, where backups are configured and the relay enforces NIP-FI, with an existing Block SSO session: - On launch the app asserts, reads backup status and finds this key listed, so it uploads nothing. The relay connection and channel list are unaffected. - A test-only build that ignored the listed shortcut enrolled, sealed with the staging tenant's real KMS-backed key and uploaded, with a stored response in about 360 ms. That re-stored my own test key. - The final build at this head was then reinstalled and relaunched with no backup failure logged. The device's staging community was added in an earlier run, because adding an enforcing organization relay by link is still blocked ([#8791](https://github.com/block/buzz/issues/8791)). An independent agent review ran; its should-fix items are addressed in the second commit. Codex's retry finding is addressed in the third, which changes only failure-path timing; the device-tested success path is unchanged. Wes's optional review notes are addressed in the last three: an exact-timing retry test, backup queues per account origin (a stalled organization backup no longer delays hosted onboarding's backup, with a test), and a subject check on each enrollment and upload request. Full `mobile-check` and `mobile-test` pass at the head. Human testing is waived under the mobile automated-testing policy; the device run above covers the at-risk flows (organization assertion, relay connection, backup status, enrollment, sealing and upload). buzz-review-completed 🤖 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>
197 lines
6.7 KiB
Dart
197 lines
6.7 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
import 'package:nostr/nostr.dart' as nostr;
|
|
|
|
import '../storage/secure_storage.dart';
|
|
import 'account_identity_store.dart';
|
|
import 'builderlab_account.dart';
|
|
import 'hosted_backup_service.dart';
|
|
import 'hpke_backup.dart';
|
|
|
|
/// Backs up an organization relay's key to the organization account.
|
|
///
|
|
/// The tenant binds a member's existing key when it issues the first relay
|
|
/// assertion for it, so [HostedRelayAuth] calls [afterAssertion] each time it
|
|
/// issues one. The account's backup listing is authoritative: a listed key
|
|
/// needs nothing more, and an unlisted one is sealed and uploaded through
|
|
/// [HostedBackupService], which keeps its pending envelope for exact replay.
|
|
///
|
|
/// A tenant without backups answers 404, and a definitive refusal cannot
|
|
/// succeed unchanged, so either ends attempts for that account and key until
|
|
/// the app restarts. Any other failure is retried at a later assertion, after
|
|
/// a delay that doubles from [firstRetryDelay] up to [maxRetryDelay]. The
|
|
/// tenant's listing is the durable record: every launch, and every assertion
|
|
/// renewal in a long-running one, checks it again until the key is listed.
|
|
class OrganizationKeyBackup {
|
|
OrganizationKeyBackup(
|
|
this.account, {
|
|
HpkeBackup? hpke,
|
|
FlutterSecureStorage? storage,
|
|
Future<bool> Function()? supportsSealing,
|
|
DateTime Function()? now,
|
|
}) : _hpke = hpke,
|
|
_storage = storage,
|
|
_supportsSealing = supportsSealing ?? HpkeBackup.supportsSealing,
|
|
_now = now ?? DateTime.now;
|
|
|
|
final BuilderlabAccountClient account;
|
|
final HpkeBackup? _hpke;
|
|
final FlutterSecureStorage? _storage;
|
|
final Future<bool> Function() _supportsSealing;
|
|
final DateTime Function() _now;
|
|
|
|
/// Account and key pairs that are backed up, or that cannot be here.
|
|
final _settled = <String>{};
|
|
final _running = <String, Future<void>>{};
|
|
final _failures = <String, int>{};
|
|
final _retryAt = <String, DateTime>{};
|
|
|
|
/// A relay refusal re-issues an assertion at once, so retries wait between
|
|
/// attempts instead of following every assertion.
|
|
static const firstRetryDelay = Duration(minutes: 1);
|
|
static const maxRetryDelay = Duration(hours: 1);
|
|
|
|
/// The latest attempt's failure, cleared by a completed attempt.
|
|
@visibleForTesting
|
|
Enum? lastFailure;
|
|
|
|
/// Completes without error. A failure is recorded and retried at the first
|
|
/// assertion after its retry delay.
|
|
Future<void> afterAssertion(String nsec, String pubkey) {
|
|
final subject = account.account?.subject;
|
|
if (subject == null) return Future.value();
|
|
final scope = '$subject\n$pubkey';
|
|
if (_settled.contains(scope)) return Future.value();
|
|
final retryAt = _retryAt[scope];
|
|
if (retryAt != null && _now().isBefore(retryAt)) return Future.value();
|
|
return _running[scope] ??= _attempt(subject, nsec, pubkey, scope)
|
|
.whenComplete(() {
|
|
// Not `=> remove`: whenComplete would await the removed future, itself.
|
|
_running.remove(scope);
|
|
});
|
|
}
|
|
|
|
Future<void> _attempt(
|
|
String subject,
|
|
String nsec,
|
|
String pubkey,
|
|
String scope,
|
|
) async {
|
|
try {
|
|
// iOS 16 can recover backups but cannot seal new ones.
|
|
if (await _canSeal()) {
|
|
await _backUp(subject, nsec, pubkey);
|
|
}
|
|
_settled.add(scope);
|
|
_failures.remove(scope);
|
|
_retryAt.remove(scope);
|
|
lastFailure = null;
|
|
} on AccountFailure catch (failure) {
|
|
_failed(scope, failure);
|
|
} on HostedBackupFailure catch (failure) {
|
|
_failed(scope, failure);
|
|
} on HpkeBackupFailure catch (failure) {
|
|
_failed(scope, failure);
|
|
} on AccountIdentityFailure catch (failure) {
|
|
_failed(scope, failure);
|
|
}
|
|
}
|
|
|
|
Future<bool> _canSeal() async {
|
|
try {
|
|
return await _supportsSealing();
|
|
} on PlatformException {
|
|
throw HpkeBackupFailure.unavailable;
|
|
} on MissingPluginException {
|
|
throw HpkeBackupFailure.unavailable;
|
|
}
|
|
}
|
|
|
|
void _failed(String scope, Enum failure) {
|
|
debugPrint('Organization key backup failed: ${failure.name}');
|
|
lastFailure = failure;
|
|
// A tenant without backups (404) and other definitive refusals cannot
|
|
// succeed unchanged.
|
|
if (const {
|
|
AccountFailure.rejected,
|
|
HostedBackupFailure.unavailable,
|
|
HostedBackupFailure.rejected,
|
|
HpkeBackupFailure.invalidEnrollment,
|
|
}.contains(failure)) {
|
|
_settled.add(scope);
|
|
return;
|
|
}
|
|
final failures = (_failures[scope] ?? 0) + 1;
|
|
_failures[scope] = failures;
|
|
final delay = firstRetryDelay * (1 << (failures - 1).clamp(0, 6));
|
|
_retryAt[scope] = _now().add(delay > maxRetryDelay ? maxRetryDelay : delay);
|
|
}
|
|
|
|
Future<void> _backUp(String subject, String nsec, String pubkey) async {
|
|
final status = await account.post(
|
|
'/v1/buzz/nsec-backups/status',
|
|
{},
|
|
expectedSubject: subject,
|
|
);
|
|
final listed = status['backup_nostr_pubkeys'];
|
|
if (status['error'] != null || listed is! List) {
|
|
throw AccountFailure.invalidResponse;
|
|
}
|
|
if (listed.contains(pubkey)) {
|
|
// An upload whose response was lost may have stored this backup.
|
|
try {
|
|
await (_storage ?? accountSecureStorage).delete(
|
|
key: HostedBackupService.pendingKey(
|
|
account.accountOrigin.origin,
|
|
subject,
|
|
pubkey,
|
|
),
|
|
);
|
|
} catch (_) {
|
|
throw HostedBackupFailure.storage;
|
|
}
|
|
return;
|
|
}
|
|
// The assertion that started this attempt bound this key, so any other
|
|
// binding means the account changed underneath it.
|
|
if (status['current_nostr_pubkey'] != pubkey) {
|
|
throw HostedBackupFailure.identityMismatch;
|
|
}
|
|
// The service checks the subject before each request. Its requests share
|
|
// the organization client's queue, so a sign-out waits for one in flight.
|
|
await HostedBackupService(
|
|
account,
|
|
_CommunityKey(nsec, pubkey),
|
|
hpke: _hpke,
|
|
storage: _storage,
|
|
).backup();
|
|
}
|
|
}
|
|
|
|
/// A community's own key. Completion is read from the account's backup
|
|
/// listing, so the receipt only needs to outlive one upload.
|
|
class _CommunityKey implements BackupIdentity {
|
|
_CommunityKey(this._nsec, this._pubkey);
|
|
|
|
final String _nsec;
|
|
final String _pubkey;
|
|
BackupReceipt? _receipt;
|
|
|
|
@override
|
|
Future<AccountIdentitySnapshot?> read() async =>
|
|
AccountIdentitySnapshot(_pubkey, _receipt);
|
|
|
|
@override
|
|
Future<T> withKeys<T>(Future<T> Function(nostr.Keys keys) operation) =>
|
|
operation(AccountIdentityStore.keysForNsec(_nsec));
|
|
|
|
@override
|
|
Future<void> confirmBackup(BackupReceipt receipt) async {
|
|
if (receipt.pubkey != _pubkey) throw AccountIdentityFailure.mismatch;
|
|
_receipt = receipt;
|
|
}
|
|
}
|