From f986c9b8a97be3b66cc467474bd856f1f283ca37 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 19 Oct 2026 15:51:55 -0700 Subject: [PATCH] Adapt staging remote-agent prototype to landed account authentication Signed-off-by: Tom Brow --- mobile/lib/main_prototype.dart | 5 +- mobile/lib/prototype/README.md | 8 +- .../prototype/remote_agent_controller.dart | 168 ++++++++---------- .../lib/shared/relay/signed_event_relay.dart | 2 + mobile/test/prototype/relay_headers_test.dart | 46 +++++ .../remote_agent_controller_test.dart | 72 ++++++++ 6 files changed, 198 insertions(+), 103 deletions(-) create mode 100644 mobile/test/prototype/relay_headers_test.dart diff --git a/mobile/lib/main_prototype.dart b/mobile/lib/main_prototype.dart index fc1dbf8c0b..d76f9da5db 100644 --- a/mobile/lib/main_prototype.dart +++ b/mobile/lib/main_prototype.dart @@ -12,7 +12,7 @@ import 'prototype/remote_agent_controller.dart'; import 'prototype/simulator_browser.dart'; import 'shared/hosted/account_identity_store.dart'; import 'shared/hosted/browser_auth.dart'; -import 'shared/hosted/builderlab_account.dart'; +import 'shared/hosted/hosted_relay_auth.dart'; import 'shared/identity_transfer/nsec_import_sheet.dart'; import 'shared/theme/app_theme.dart'; import 'shared/theme/grid.dart'; @@ -38,7 +38,8 @@ class RemotePrototype extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final controller = useMemoized( () => RemoteAgentController( - account: BuilderlabAccountClient(), + account: ref.read(builderlabAccountProvider), + relayAuth: ref.read(hostedRelayAuthProvider), browser: BrowserAuth( openBrowser: kDebugMode ? openSimulatorChrome : null, ), diff --git a/mobile/lib/prototype/README.md b/mobile/lib/prototype/README.md index 96f841b2b0..940b0fc850 100644 --- a/mobile/lib/prototype/README.md +++ b/mobile/lib/prototype/README.md @@ -1,8 +1,8 @@ # Throwaway staging remote-agent prototype -This is a separate mobile entry point on Wes's mobile-onboarding branch. It uses the existing hosted-account session and identity-binding code, then the desktop BuilderLab Beekeeper API to register and activate an agent. +This is a separate mobile entry point rebased onto main `139f8903d5fd075a172b7555d88ae0e616cdcb39`, including the final authentication implementation from PR #8375 (`b6aa5539e3a44fe32a4bfe2963dfb23f10316985`). It uses the existing hosted-account session and identity-binding code, then the desktop BuilderLab Beekeeper API to register and activate an agent. -Entry point: `lib/main_prototype.dart`. Required build define: `BUZZ_BUILDERLAB_URL=https://app.builderlabstaging.xyz`. The running test app has bundle identifier `xyz.block.buzz.dev.mobile.remoteprototype` and display name **Buzz Remote Prototype**. +Entry point: `lib/main_prototype.dart`. Required build defines: `BUZZ_BUILDERLAB_URL=https://app.builderlabstaging.xyz`, `BUZZ_AUTH_DOMAINS=communities.public.builderlabstaging.xyz`, and `BUZZ_HOSTED_RELAY_AUTH=true`. The running test app has bundle identifier `xyz.block.buzz.dev.mobile.remoteprototype` and display name **Buzz Remote Prototype**. ## Simulator use @@ -12,10 +12,12 @@ Entry point: `lib/main_prototype.dart`. Required build define: `BUZZ_BUILDERLAB_ 4. Tap **Create remote agent**. If an operation fails, **Resume agent setup** reuses the saved registration ID and idempotency key. 5. Open the community in Buzz to interact with the agent. -The prototype imports an existing owner identity; it does not implement new-user key backup or recovery. A debug-only native method transfers a one-time simulator launch environment value into device-only Keychain. It is compiled only for a debug simulator and checks the prototype bundle identifier. No key is embedded in source or build configuration. +The prototype reuses an existing owner identity only after the current account binding confirms the same key. It adopts the saved key into the account-scoped slot without deleting the old record. Community admission now uses the shared encrypted-backup, binding verification, and relay probe services. It does not implement new-user identity selection or recovery UI. A debug-only native method transfers a one-time simulator launch environment value into device-only Keychain. It is compiled only for a debug simulator and checks the prototype bundle identifier. No key is embedded in source or build configuration. The agent's private key remains on BuilderLab. Activation uses the owner's NIP-OA authorization, explicitly subscribes the agent to the selected community, and publishes a relay listing with owner-only responses and parallelism 1. Attestation alone only reconciles server-configured communities, so it is insufficient for a newly created community. The link button beside an existing agent idempotently subscribes it to the selected community. Pending setup contains public identifiers only and is scoped to the staging account. This is a local experiment, not a production onboarding implementation. Browser account sign-in, live registration, relay admission and agent response must all be verified before treating the end-to-end flow as working. Verified on 2026-10-16: Chrome sign-in, existing Tom identity binding, disposable public staging community creation, agent registration/attestation/subscription, and a real reply in the private `prototype-test` channel. Agent `8397ca0794853c62d53c910775ac316474ec7f577bde8c5199091c15dae47953` replied “Remote agent is ready.” to event `7246a58737e32d1f7d60d75747c15f254dafa0b8c2742ecf6ac73485909671b4`. No production state was changed. + +Adaptation source was recovered from Codex startup-cleanup snapshot `902ca75bae20e91d153acc40b3245e8f3e6384b8`; the original branch is preserved. Current worktree: `/Users/tomb/.codex/worktrees/remote-prototype-current/buzz`, branch `remote-prototype-current`. The app and prototype share one account client. Discovery uses the joined-community API; both agent listing publication and chat use federated relay authentication. The October 16 evidence above is historical, not verification of this adaptation. diff --git a/mobile/lib/prototype/remote_agent_controller.dart b/mobile/lib/prototype/remote_agent_controller.dart index 4517ecee53..f8141be09b 100644 --- a/mobile/lib/prototype/remote_agent_controller.dart +++ b/mobile/lib/prototype/remote_agent_controller.dart @@ -7,7 +7,6 @@ import 'package:nostr/nostr.dart' as nostr; import 'package:shared_preferences/shared_preferences.dart'; import 'package:uuid/uuid.dart'; -import '../shared/community/community.dart'; import '../shared/community/community_storage.dart'; import '../shared/deeplink/deep_link.dart'; import '../shared/hosted/account_identity_store.dart'; @@ -15,7 +14,11 @@ import '../shared/hosted/browser_auth.dart'; import '../shared/hosted/builderlab_account.dart'; import '../shared/hosted/hosted_communities.dart'; import '../shared/hosted/hosted_environment.dart'; -import '../shared/hosted/web_connect_binding.dart'; +import '../shared/hosted/hosted_admission.dart'; +import '../shared/hosted/hosted_backup_service.dart'; +import '../shared/hosted/hosted_community_creation.dart'; +import '../shared/hosted/hosted_relay_auth.dart'; +import '../shared/relay/relay_provider.dart'; import '../shared/relay/signed_event_relay.dart'; /// Staging-only experiment using the same account and Beekeeper contracts as desktop. @@ -23,11 +26,18 @@ class RemoteAgentController extends ChangeNotifier { RemoteAgentController({ required this.account, required this.browser, - required this.keys, - }); + required AccountIdentityStore keys, + required this.relayAuth, + }) : _keys = keys; final BuilderlabAccountClient account; final BrowserAuth browser; - final AccountIdentityStore keys; + final AccountIdentityStore _keys; + final HostedRelayAuth relayAuth; + AccountIdentityStore get keys { + final subject = account.account?.subject; + return subject == null ? _keys : _keys.forSubject(subject); + } + final _subscriptions = >[]; bool busy = false; int _operation = 0; @@ -104,12 +114,13 @@ class RemoteAgentController extends ChangeNotifier { }); Future refresh() async { - owner = (await keys.read())?.pubkey; final session = account.account; if (session == null) { status = 'Sign in to your staging BuilderLab account.'; return; } + await adoptBoundIdentity(); + owner = (await keys.read())?.pubkey; _scope = 'remote-prototype:${HostedEnvironment.origin.host}:${session.subject}'; final prefs = await SharedPreferences.getInstance(); @@ -141,12 +152,10 @@ class RemoteAgentController extends ChangeNotifier { Future createTestCommunity() => run(() async { await ensureBinding(); - final response = await account.post('/v1/buzz/communities', { - 'name': 'tomb-remote-prototype-20261016', - }); - if (response['error'] != null) { - throw StateError('Staging community creation was not confirmed.'); - } + final subject = account.account!.subject; + await HostedCreateCommunity( + 'tomb-remote-prototype-20261016', + ).provision(account, owner!, () => account.account?.subject == subject); await refresh(); if (communities.isEmpty) { throw StateError( @@ -165,86 +174,40 @@ class RemoteAgentController extends ChangeNotifier { notifyListeners(); } - Future ensureBinding() async { + /// Migrate only a locally saved key confirmed by the current server binding. + /// Keep the original record and refuse account/key replacement. + Future adoptBoundIdentity() async { final subject = account.account?.subject; - final identity = await keys.read(); - if (subject == null || identity == null) { - throw StateError( - 'Sign in and transfer your existing Buzz identity first.', - ); + if (subject == null) throw AccountFailure.signedOut; + final scoped = _keys.forSubject(subject); + final response = await account.currentIdentity(); + if (account.account?.subject != subject) { + throw AccountFailure.differentAccount; } - final current = await account.currentIdentity(); - final bound = (current['identity'] as Map?)?['pubkey_hex']; - if (bound == identity.pubkey) return; - if (bound != null) { - throw StateError( - 'This account is bound to a different Buzz identity. Nothing was replaced.', - ); + if (response['error'] != null) throw AccountFailure.invalidResponse; + final bound = (response['identity'] as Map?)?['pubkey_hex']; + final local = await scoped.read(); + if (local != null) { + if (bound != local.pubkey) throw AccountIdentityFailure.mismatch; + return; } - if (current['error'] != null && - (current['error'] as Map)['setup_needed'] != true) { - throw StateError( - 'BuilderLab could not verify the existing identity binding.', - ); - } - final challenge = await account.post( - '/v1/buzz/nostr-identities/challenge', - {'origin': HostedEnvironment.origin.toString()}, - ); - final fields = { - 'audience': 'buzz:nostr-identity', - 'action': 'bind_nostr_identity', - 'protocol': 'buzz-nostr-identity', - 'version': '1', - 'return': 'browser_fragment_v1', - 'callback_url': HostedEnvironment.origin.resolve('/buzz').toString(), - }; - for (final field in [ - 'challenge_id', - 'nonce', - 'verification_code', - 'origin', - 'expires_at', - ]) { - if (challenge[field] is! String) { - throw StateError('Invalid identity challenge.'); - } - fields[field] = challenge[field] as String; - } - final request = parseNostrBindDeepLink( - Uri(scheme: 'buzz', host: 'nostr-bind', queryParameters: fields), - ); - if (request == null) throw StateError('Invalid identity challenge.'); - await WebConnectBindingService().complete( - request: request, - subject: subject, - expectedPubkey: identity.pubkey, - withKeys: keys.withKeys, - verify: - ({ - required subject, - required challengeId, - required nonce, - required signedPayload, - }) async { - if (account.account?.subject != subject) { - throw StateError('Account changed.'); - } - final result = await account - .post('/v1/buzz/nostr-identities/verify', { - 'challenge_id': challengeId, - 'nonce': nonce, - 'signed_payload': signedPayload, - }); - final key = (result['identity'] as Map?)?['pubkey_hex']; - if (key is! String) { - throw StateError('Identity binding was not confirmed.'); - } - return key; - }, + if (bound is! String) throw AccountIdentityFailure.absent; + await scoped.adoptSaved( + bound, + checkCurrent: () { + if (account.account?.subject != subject) { + throw AccountFailure.differentAccount; + } + }, ); } + Future ensureBinding() async { + await adoptBoundIdentity(); + owner = (await keys.read())?.pubkey; + if (owner == null) throw AccountIdentityFailure.absent; + } + Future _savePending(Map value) async { final prefs = await SharedPreferences.getInstance(); if (!await prefs.setString('$_scope:pending', jsonEncode(value))) { @@ -313,6 +276,9 @@ class RemoteAgentController extends ChangeNotifier { await keys.withKeys((key) async { await submitSignedEventOnce( wsUrl: selected.relayUrl, + headers: await relayAuth.headers( + RelayConfig(baseUrl: selected.relayUrl, nsec: key.nsec), + ), nsec: key.nsec, kind: 30177, content: jsonEncode({ @@ -354,25 +320,32 @@ class RemoteAgentController extends ChangeNotifier { Future saveCommunity() async { final selected = community; if (selected == null) throw StateError('Choose a community first.'); - await keys.withKeys((key) async { + final subject = account.account?.subject; + if (subject == null || owner == null) throw AccountFailure.signedOut; + await HostedBackupService( + account, + keys, + ).backup(isCurrent: () => account.account?.subject == subject); + final admission = HostedAdmission( + account: account, + store: _keys, + relayAuth: relayAuth, + ); + try { + final prepared = await admission.prepare(selected, subject, owner!); final storage = CommunityStorage(); final existing = (await storage.loadAll()) .where((c) => c.relayUrl == selected.relayUrl) .firstOrNull; - if (existing != null && existing.pubkey != key.public) { + if (existing != null && existing.pubkey != prepared.pubkey) { throw StateError('Saved community has another identity.'); } - final saved = - existing ?? - Community.create( - name: selected.name, - relayUrl: selected.relayUrl, - nsec: key.nsec, - pubkey: key.public, - ); + final saved = existing ?? prepared; await storage.save(saved); await storage.saveActiveId(saved.id); - }); + } finally { + admission.dispose(); + } } @override @@ -381,7 +354,6 @@ class RemoteAgentController extends ChangeNotifier { unawaited(subscription.cancel()); } unawaited(browser.dispose()); - account.dispose(); super.dispose(); } } diff --git a/mobile/lib/shared/relay/signed_event_relay.dart b/mobile/lib/shared/relay/signed_event_relay.dart index b0106639eb..571af77277 100644 --- a/mobile/lib/shared/relay/signed_event_relay.dart +++ b/mobile/lib/shared/relay/signed_event_relay.dart @@ -73,6 +73,7 @@ Future submitSignedEventOnce({ required List> tags, int? createdAt, Duration timeout = const Duration(seconds: 12), + Map headers = const {}, }) async { final privateKey = nostr.Nip19.decode(payload: nsec).data; if (privateKey.isEmpty) throw const FormatException('Invalid nsec'); @@ -122,6 +123,7 @@ Future submitSignedEventOnce({ } }, ); + socket.headers = headers; final resultFuture = result.future.timeout(timeout); try { await socket.connect(); diff --git a/mobile/test/prototype/relay_headers_test.dart b/mobile/test/prototype/relay_headers_test.dart new file mode 100644 index 0000000000..53973c821a --- /dev/null +++ b/mobile/test/prototype/relay_headers_test.dart @@ -0,0 +1,46 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:buzz/shared/relay/signed_event_relay.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nostr/nostr.dart' as nostr; + +void main() { + test('prototype publication carries federated admission header', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final received = server.first.then((request) async { + expect( + request.headers.value('Nostr-Federated-Identity'), + 'Bearer fixture', + ); + final socket = await WebSocketTransformer.upgrade(request); + socket.add(jsonEncode(['AUTH', 'fixture-challenge'])); + socket.listen((raw) { + final frame = jsonDecode(raw as String) as List; + final event = frame[1] as Map; + if (frame.first == 'AUTH') { + socket.add(jsonEncode(['OK', event['id'], true, ''])); + } else { + expect(frame.first, 'EVENT'); + expect(event['kind'], 30177); + socket.add(jsonEncode(['OK', event['id'], true, ''])); + } + }); + return socket; + }); + final key = nostr.Keys('0' * 63 + '1'); + try { + await submitSignedEventOnce( + wsUrl: 'ws://127.0.0.1:${server.port}', + nsec: key.nsec, + kind: 30177, + content: '{}', + tags: const [], + headers: const {'Nostr-Federated-Identity': 'Bearer fixture'}, + ); + await (await received).close(); + } finally { + await server.close(force: true); + } + }); +} diff --git a/mobile/test/prototype/remote_agent_controller_test.dart b/mobile/test/prototype/remote_agent_controller_test.dart index a316a47285..63a57ff64d 100644 --- a/mobile/test/prototype/remote_agent_controller_test.dart +++ b/mobile/test/prototype/remote_agent_controller_test.dart @@ -1,9 +1,67 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:buzz/shared/hosted/account_identity_store.dart'; +import 'package:buzz/shared/hosted/builderlab_account.dart'; +import 'package:buzz/shared/hosted/browser_auth.dart'; +import 'package:buzz/shared/hosted/hosted_relay_auth.dart'; + import 'package:flutter_test/flutter_test.dart'; import 'package:nostr/nostr.dart' as nostr; import 'package:buzz/prototype/remote_agent_controller.dart'; import 'package:buzz/shared/crypto/nip_oa.dart'; void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => FlutterSecureStorage.setMockInitialValues({})); + test( + 'verified binding migrates legacy key without replacing original', + () async { + final key = nostr.Keys('0' * 63 + '1'); + final store = AccountIdentityStore(); + await store.create(key.nsec); + final account = BoundAccount(key.public); + final auth = HostedRelayAuth(account); + final controller = RemoteAgentController( + account: account, + browser: BrowserAuth(), + keys: store, + relayAuth: auth, + ); + await controller.adoptBoundIdentity(); + expect( + (await store.forSubject('prototype-owner').read())!.pubkey, + key.public, + ); + expect((await store.read())!.pubkey, key.public); + expect((await controller.keys.read())!.backup, isNull); + controller.dispose(); + auth.dispose(); + account.dispose(); + }, + ); + test('different bound identity cannot acquire legacy key', () async { + final key = nostr.Keys('0' * 63 + '1'); + final other = nostr.Keys('0' * 63 + '2'); + final store = AccountIdentityStore(); + await store.create(key.nsec); + final account = BoundAccount(other.public); + final auth = HostedRelayAuth(account); + final controller = RemoteAgentController( + account: account, + browser: BrowserAuth(), + keys: store, + relayAuth: auth, + ); + await expectLater( + controller.adoptBoundIdentity(), + throwsA(AccountIdentityFailure.absent), + ); + expect(await store.forSubject('prototype-owner').read(), isNull); + expect((await store.read())!.pubkey, key.public); + controller.dispose(); + auth.dispose(); + account.dispose(); + }); + test('owner proof verifies for only the provisioned agent key', () { final owner = nostr.Keys('0' * 63 + '1'); final agent = nostr.Keys('0' * 63 + '2'); @@ -27,3 +85,17 @@ void main() { expect(() => requireStatus({}, 'ACTIVE', 1), throwsStateError); }); } + +class BoundAccount extends BuilderlabAccountClient { + BoundAccount(this.pubkey); + final String pubkey; + @override + BuilderlabAccount get account => BuilderlabAccount( + subject: 'prototype-owner', + expiresAt: DateTime.utc(2030), + ); + @override + Future> currentIdentity() async => { + 'identity': {'pubkey_hex': pubkey}, + }; +}