mirror of
https://github.com/block/buzz.git
synced 2026-11-07 01:41:06 +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>
188 lines
5.7 KiB
Dart
188 lines
5.7 KiB
Dart
import '../storage/serial_queue.dart';
|
|
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:crypto/crypto.dart';
|
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
|
|
import '../storage/secure_storage.dart';
|
|
|
|
import 'account_identity_store.dart';
|
|
import 'builderlab_account.dart';
|
|
import 'hpke_backup.dart';
|
|
|
|
enum HostedBackupFailure {
|
|
identityMismatch,
|
|
storage,
|
|
invalidResponse,
|
|
rejected,
|
|
unavailable,
|
|
}
|
|
|
|
/// A pending ciphertext is durable before its first upload. Uncertain uploads
|
|
/// replay that same envelope; only confirmed storage produces an admission receipt.
|
|
class HostedBackupService {
|
|
HostedBackupService(
|
|
this.account,
|
|
this.identity, {
|
|
HpkeBackup? hpke,
|
|
FlutterSecureStorage? storage,
|
|
}) : _hpke = hpke ?? HpkeBackup(),
|
|
_storage = storage ?? accountSecureStorage;
|
|
|
|
final BuilderlabAccountClient account;
|
|
final BackupIdentity identity;
|
|
final HpkeBackup _hpke;
|
|
final FlutterSecureStorage _storage;
|
|
|
|
/// One queue per account origin: a stalled organization backup must not
|
|
/// hold up the hosted backup onboarding waits on.
|
|
static final _queues = <String, SerialQueue>{};
|
|
|
|
/// Scoped to the account's own origin, so hosted and organization backups
|
|
/// of one key never share a pending envelope.
|
|
static String pendingKey(String backend, String subject, String pubkey) =>
|
|
'buzz_backup_pending_v2_${sha256.convert(utf8.encode(jsonEncode([backend, subject, pubkey])))}';
|
|
|
|
Future<void> backup({bool Function()? isCurrent}) {
|
|
final queue = _queues.putIfAbsent(
|
|
account.accountOrigin.origin,
|
|
SerialQueue.new,
|
|
);
|
|
final result = queue.run(() => _backup(isCurrent: isCurrent));
|
|
return result;
|
|
}
|
|
|
|
Future<void> _backup({
|
|
bool reenrolled = false,
|
|
bool Function()? isCurrent,
|
|
}) async {
|
|
void checkCurrent() {
|
|
if (isCurrent != null && !isCurrent()) throw AccountFailure.unavailable;
|
|
}
|
|
|
|
checkCurrent();
|
|
final subject = account.account?.subject;
|
|
final local = await identity.read();
|
|
if (subject == null || local == null) {
|
|
throw HostedBackupFailure.identityMismatch;
|
|
}
|
|
final backend = account.accountOrigin.origin;
|
|
final key = pendingKey(backend, subject, local.pubkey);
|
|
if (local.hasBackupFor(backend: backend, subject: subject)) return;
|
|
void checkAccount() {
|
|
if (account.account?.subject != subject) {
|
|
throw HostedBackupFailure.identityMismatch;
|
|
}
|
|
}
|
|
|
|
Map<String, dynamic>? envelope;
|
|
try {
|
|
final raw = await _storage.read(key: key);
|
|
if (raw != null) {
|
|
final record = jsonDecode(raw) as Map<String, dynamic>;
|
|
if (record['backend'] != backend ||
|
|
record['subject'] != subject ||
|
|
record['pubkey'] != local.pubkey) {
|
|
throw HostedBackupFailure.identityMismatch;
|
|
}
|
|
envelope = Map<String, dynamic>.from(record['envelope'] as Map);
|
|
HpkeBackup.associatedData(envelope);
|
|
if (envelope['nostr_pubkey'] != local.pubkey) {
|
|
throw HostedBackupFailure.identityMismatch;
|
|
}
|
|
}
|
|
} on HostedBackupFailure {
|
|
rethrow;
|
|
} catch (_) {
|
|
throw HostedBackupFailure.storage;
|
|
}
|
|
checkAccount();
|
|
checkCurrent();
|
|
if (envelope == null) {
|
|
final Map<String, dynamic> enrollment;
|
|
try {
|
|
enrollment = await account.post(
|
|
'/v1/buzz/nsec-backups/enrollment',
|
|
{},
|
|
expectedSubject: subject,
|
|
);
|
|
} on AccountFailure catch (failure) {
|
|
if (failure == AccountFailure.rejected) {
|
|
throw HostedBackupFailure.unavailable;
|
|
}
|
|
rethrow;
|
|
}
|
|
checkAccount();
|
|
if (enrollment['nostr_pubkey'] != local.pubkey) {
|
|
throw HostedBackupFailure.identityMismatch;
|
|
}
|
|
envelope = await identity.withKeys(
|
|
(keys) => _hpke.seal(enrollment: enrollment, secretHex: keys.secret),
|
|
);
|
|
checkAccount();
|
|
final raw = jsonEncode({
|
|
'backend': backend,
|
|
'subject': subject,
|
|
'pubkey': local.pubkey,
|
|
'envelope': envelope,
|
|
});
|
|
try {
|
|
await _storage.write(key: key, value: raw);
|
|
if (await _storage.read(key: key) != raw) {
|
|
throw HostedBackupFailure.storage;
|
|
}
|
|
} catch (_) {
|
|
throw HostedBackupFailure.storage;
|
|
}
|
|
}
|
|
checkAccount();
|
|
checkCurrent();
|
|
final pending = envelope!;
|
|
final Map<String, dynamic> result;
|
|
try {
|
|
result = await account.post(
|
|
'/v1/buzz/nsec-backups',
|
|
pending,
|
|
expectedSubject: subject,
|
|
);
|
|
} on AccountFailure catch (failure) {
|
|
if (failure != AccountFailure.rejected) rethrow;
|
|
checkAccount();
|
|
checkCurrent();
|
|
// Only a definitive rejection permits replacing the pending envelope.
|
|
// A timeout/5xx/unknown response always preserves it for exact replay.
|
|
if (reenrolled) throw HostedBackupFailure.rejected;
|
|
try {
|
|
await _storage.delete(key: key);
|
|
if (await _storage.read(key: key) != null) {
|
|
throw HostedBackupFailure.storage;
|
|
}
|
|
} catch (_) {
|
|
throw HostedBackupFailure.storage;
|
|
}
|
|
return _backup(reenrolled: true, isCurrent: isCurrent);
|
|
}
|
|
checkAccount();
|
|
if (result['stored'] != true ||
|
|
result['backup_id'] != pending['backup_id']) {
|
|
throw HostedBackupFailure.invalidResponse;
|
|
}
|
|
await identity.confirmBackup(
|
|
BackupReceipt(
|
|
backend: backend,
|
|
subject: subject,
|
|
pubkey: local.pubkey,
|
|
backupId: pending['backup_id'] as String,
|
|
),
|
|
);
|
|
// Receipt is authoritative after this point; a failed cleanup cannot cause
|
|
// another enrollment/upload on restart.
|
|
try {
|
|
await _storage.delete(key: key);
|
|
} catch (_) {
|
|
/* Retry is unnecessary once the receipt is durable. */
|
|
}
|
|
}
|
|
}
|