Files
buzz/mobile/lib/shared/hosted/hosted_relay_auth.dart
Tom Brow 90de03aa63 feat(mobile): back up nsec to org builderlab account (#9029)
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>
2026-11-04 12:11:31 -08:00

370 lines
12 KiB
Dart

import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
import '../relay/relay_provider.dart';
import 'builderlab_account.dart';
import 'account_identity_store.dart';
import 'hosted_environment.dart';
import 'organization_environment.dart';
import 'organization_key_backup.dart';
import '../push/push_bridge.dart';
final builderlabAccountProvider = Provider<BuilderlabAccountClient>((ref) {
final account = BuilderlabAccountClient();
ref.onDispose(account.dispose);
return account;
});
/// The organization's own account session, separate from the hosted account.
/// Null unless this build configures [OrganizationEnvironment].
final organizationAccountProvider = Provider<BuilderlabAccountClient?>((ref) {
final origin = OrganizationEnvironment.origin;
if (origin == null) return null;
final account = BuilderlabAccountClient(
accountOrigin: origin,
// The notification extension mints its own assertions for these relays.
onSessionChanged: (credential) => shareOrganizationSessionWithNotifications(
origin: origin,
credential: credential,
relayHosts: OrganizationEnvironment.relayHosts,
),
);
ref.onDispose(account.dispose);
return account;
});
/// Outlives relay-auth rebuilds, so its settled keys and in-flight attempts
/// are never duplicated. Null unless this build configures an organization.
final organizationKeyBackupProvider = Provider<OrganizationKeyBackup?>((ref) {
final organization = ref.watch(organizationAccountProvider);
return organization == null ? null : OrganizationKeyBackup(organization);
});
final hostedRelayAuthProvider = Provider<HostedRelayAuth>((ref) {
final auth = HostedRelayAuth(
ref.watch(builderlabAccountProvider),
organization: ref.watch(organizationAccountProvider),
organizationBackup: ref.watch(organizationKeyBackupProvider),
);
ref.onDispose(auth.dispose);
return auth;
});
/// Each transport owns its client lifetime; configuration and auth are shared.
final hostedHttpClientFactoryProvider = Provider<http.Client Function()>((ref) {
final auth = ref.watch(hostedRelayAuthProvider);
final config = ref.watch(relayConfigProvider);
return () => HostedRelayHttpClient(auth, config);
});
/// One account session per realm, with a short-lived assertion for the current
/// relay/key. Hosted communities use [account] when [enabled]; the build's
/// organization relays use [organization]. Enable alongside backend issuance;
/// legacy and self-hosted builds stay usable.
class HostedRelayAuth extends ChangeNotifier {
HostedRelayAuth(
this.account, {
this.organization,
this.organizationBackup,
this.organizationRelay = OrganizationEnvironment.protects,
this.enabled = const bool.fromEnvironment('BUZZ_HOSTED_RELAY_AUTH'),
DateTime Function()? now,
}) : _now = now ?? DateTime.now;
final BuilderlabAccountClient account;
final BuilderlabAccountClient? organization;
/// Backs up each organization relay key once its assertion has bound it.
final OrganizationKeyBackup? organizationBackup;
final bool Function(Uri relay) organizationRelay;
final bool enabled;
final DateTime Function() _now;
({
String relay,
String pubkey,
String token,
DateTime expiry,
BuilderlabAccount account,
})?
_cached;
// Per account client, so a stalled issuer never delays an unrelated one.
final _pending = <BuilderlabAccountClient, Future<void>>{};
bool _disposed = false;
// Orders issuance and refusals, so a stale result never replaces a newer
// failure for another relay or key.
int _sequence = 0;
int _failureSequence = 0;
String? failedRelay;
String? failedPubkey;
AccountFailure? failure;
bool protects(RelayConfig config) => _accountFor(config) != null;
/// Whether this relay uses the organization's session, not the hosted one.
bool organizationProtects(RelayConfig config) =>
organization != null && identical(_accountFor(config), organization);
BuilderlabAccountClient? _accountFor(RelayConfig config) {
final relay = Uri.parse(config.baseUrl);
if (organization != null && organizationRelay(relay)) {
return organization;
}
if (enabled &&
HostedEnvironment.requiresAuth(relay.host) &&
HostedEnvironment.supportsRelay(relay)) {
return account;
}
return null;
}
/// Headers for optional reads, without starting account work or changing
/// recovery state. Null means protected metadata must wait for active auth.
Map<String, String>? cachedHeaders(RelayConfig config) {
if (_disposed) return null;
if (!protects(config)) return const {};
final cached = _cached;
if (cached == null ||
cached.relay != Uri.parse(config.wsUrl).replace(path: '').toString() ||
cached.pubkey != pubkeyFromNsec(config.nsec) ||
!identical(cached.account, _accountFor(config)?.account) ||
!cached.expiry.isAfter(_now())) {
return null;
}
return {'Nostr-Federated-Identity': 'Bearer ${cached.token}'};
}
Future<Map<String, String>> headers(RelayConfig config) async {
final client = _accountFor(config);
if (client == null) return const {};
try {
return await _headers(config, client);
} on AccountFailure catch (failure) {
// Without an organization session, connect as before and let the relay
// decide: an enforcing relay refuses, and sign-in is offered.
if (failure == AccountFailure.signedOut &&
identical(client, organization)) {
return const {};
}
rethrow;
}
}
Future<Map<String, String>> _headers(
RelayConfig config,
BuilderlabAccountClient client,
) async {
final relay = Uri.parse(config.wsUrl).replace(path: '').toString();
final pubkey = pubkeyFromNsec(config.nsec);
if (pubkey == null) throw AccountFailure.denied;
while (!_disposed) {
final cached = _cached;
if (cached != null &&
cached.relay == relay &&
cached.pubkey == pubkey &&
identical(cached.account, client.account) &&
cached.expiry.isAfter(_now())) {
return {'Nostr-Federated-Identity': 'Bearer ${cached.token}'};
}
final pending = _pending[client];
if (pending != null) {
// Different communities share account restoration, never assertions.
try {
await pending;
} on AccountFailure {
if (failedRelay == config.baseUrl && failedPubkey == pubkey) rethrow;
}
continue;
}
final operation = _issue(config, client, relay, pubkey);
_pending[client] = operation;
try {
await operation;
} finally {
if (identical(_pending[client], operation)) _pending.remove(client);
}
}
throw AccountFailure.unavailable;
}
Future<void> _issue(
RelayConfig config,
BuilderlabAccountClient account,
String relay,
String pubkey,
) async {
final started = ++_sequence;
try {
if (account.account == null && await account.restore() == null) {
throw AccountFailure.signedOut;
}
final session = account.account!;
final current = await account.currentIdentity();
if (!identical(session, account.account)) throw AccountFailure.denied;
final identity = current['identity'];
if (current['error'] != null) throw AccountFailure.invalidResponse;
if (identity == null && identical(account, organization)) {
// The organization's issuer binds the proven key on first issuance;
// that binding is how a member's existing key joins the organization.
} else if (identity == null) {
// Issuance can create a binding. Reconnect must not silently give a
// different, unbound hosted account this community's saved identity.
final AccountIdentitySnapshot? saved;
try {
saved = await AccountIdentityStore()
.forSubject(session.subject)
.read();
} on AccountIdentityFailure {
throw AccountFailure.storage;
}
if (saved?.pubkey != pubkey ||
!saved!.hasBackupFor(
backend: BuilderlabAccountClient.origin.origin,
subject: session.subject,
)) {
throw AccountFailure.denied;
}
} else if (identity is! Map || identity['pubkey_hex'] != pubkey) {
throw AccountFailure.denied;
}
final value = await account.relayAssertion(
relay,
config.nsec!,
expectedSubject: session.subject,
);
final token = value['assertion'];
final expiry = value['expires_at'];
if (token is! String ||
token.isEmpty ||
token.length > 16384 ||
token.contains(RegExp(r'[\r\n]')) ||
value['nostr_pubkey'] != pubkey ||
expiry is! int) {
throw AccountFailure.invalidResponse;
}
final now = _now().millisecondsSinceEpoch ~/ 1000;
if (expiry <= now) {
throw AccountFailure.invalidResponse;
}
final expires = DateTime.fromMillisecondsSinceEpoch(
expiry * 1000,
isUtc: true,
);
if (_disposed) throw AccountFailure.unavailable;
if (!identical(session, account.account)) {
return;
}
_cached = (
relay: relay,
pubkey: pubkey,
token: token,
expiry: expires.subtract(
Duration(seconds: (expiry - now) > 60 ? 30 : (expiry - now) ~/ 2),
),
account: session,
);
// Issuance alone cannot prove admission after a relay refusal.
if (failure != AccountFailure.denied) connected(config);
if (identical(account, organization)) {
unawaited(organizationBackup?.afterAssertion(config.nsec!, pubkey));
}
} on AccountFailure catch (error) {
// Issuers run in parallel, so a failure that started before another
// relay's unresolved failure was recorded must not replace it.
final otherScope =
failure != null &&
(failedRelay != config.baseUrl || failedPubkey != pubkey);
if (!_disposed && !(otherScope && _failureSequence > started)) {
_record(config.baseUrl, pubkey, error, started);
}
rethrow;
}
}
/// Clear a scoped failure only after this community actually reconnects.
void connected(RelayConfig config) {
if (_disposed || (failedRelay != null && failedRelay != config.baseUrl)) {
return;
}
failure = null;
failedRelay = null;
failedPubkey = null;
notifyListeners();
}
void rejected(
RelayConfig config,
String? usedToken, {
bool terminal = false,
}) {
if (!protects(config) || usedToken == null) return;
if ('Bearer ${_cached?.token}' == usedToken) _cached = null;
if (terminal && !_disposed) {
_record(
config.baseUrl,
pubkeyFromNsec(config.nsec),
AccountFailure.denied,
++_sequence,
);
}
}
void _record(
String relay,
String? pubkey,
AccountFailure error,
int sequence,
) {
failedRelay = relay;
failedPubkey = pubkey;
failure = error;
_failureSequence = sequence;
notifyListeners();
}
@override
void dispose() {
_disposed = true;
_cached = null;
super.dispose();
}
}
/// Captures the signing context with the request. Never follows redirects with
/// credentials or replays a failed write automatically.
class HostedRelayHttpClient extends http.BaseClient {
HostedRelayHttpClient(
this.auth,
this.config, {
http.Client? inner,
this.canSend,
}) : _inner = inner ?? http.Client();
final HostedRelayAuth auth;
final RelayConfig config;
final http.Client _inner;
final bool Function()? canSend;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
final origin = Uri.parse(config.baseUrl);
final scoped = request.url.origin == origin.origin && auth.protects(config);
if (scoped) {
request.followRedirects = false;
request.headers.addAll(await auth.headers(config));
}
if (canSend != null && !canSend!()) {
throw StateError('Relay request expired');
}
final response = await _inner.send(request);
if (scoped && (response.statusCode == 401 || response.statusCode == 403)) {
auth.rejected(config, request.headers['Nostr-Federated-Identity']);
}
return response;
}
@override
void close() => _inner.close();
}