Repository navigation
fix(mobile): require Builderlab consent for existing identities - #8823
Conversation
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> Signed-off-by: Wes <[email protected]>
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> Signed-off-by: Wes <[email protected]>
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> Signed-off-by: Wes <[email protected]>
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> Signed-off-by: Wes <[email protected]>
🔐 Codex Security Review
Review SummaryOverall Risk: NONE
FindingsNo concrete security, correctness, or reliability findings were identified. Notes
Generated by Codex Security Review | |
kalvinnchau
left a comment
There was a problem hiding this comment.
🤖 Found one P2 lifecycle issue: declining backup leaves the local identity selection permanently committed.
| _pendingBackup = backup; | ||
| _set(HostedFlowStage.confirmBackup); | ||
| return; | ||
| } |
There was a problem hiding this comment.
🤖 [P2] Defer irreversible identity commitment until consent
With two saved community keys A and B, choose A, decline the backup prompt, then start Connect again. Identity resolution has already adopted A into the create-only account slot, so re-entry skips the chooser and B can no longer be selected—even though setup was cancelled before binding or upload. An isolated reproduction returned confirmBackup with zero choices and A still stored.
HostedIdentityService writes during adoption/generation (hosted_identity_service.dart:165–189), before this gate; AccountIdentityStore rejects replacement (account_identity_store.dart:135–160). Defer that write until approval, or present the disclosure before the irreversible selection/create action. Add a regression that declines on A and then successfully selects B.
There was a problem hiding this comment.
🤖 Wes’s AI agent (Brain): addressed in e11025ebed, updated against main 193fda22 and independently reviewed by Carl with no blockers.
- Existing and imported keys now stop before the create-only account-slot write. Cancel/restart can select B after declining A; no rollback or deletion is needed.
- Confirmation identifies the receiving account by verified email, offers sign-out, and cannot proceed without account identification.
- Copy discloses permanence and service-side decryption/recovery, uses “Cancel setup,” and explicitly warns that cancellation does not undo previous linking or an upload whose acknowledgement was lost.
- Browser binding has its own “Link and continue” confirmation and no-upload wording. Freshly generated keys avoid the additional backup prompt; unconfirmed retries/restarts conservatively ask again.
- The scope is Builderlab hosted onboarding; organization-managed backup and server recovery policy are separate.
96 focused tests passed during implementation; final analysis, mandatory hooks (including the broad mobile suite), and three isolated behavioral mutations passed. The revised journeys cover A → cancel → B, restart/Back/stale callbacks, restored-account identification, and accepted upload → lost acknowledgement → retry/cancel/restart. The existing browser journey now also checks decline before account-slot adoption and /verify.
The PR is draft pending native-device and human acceptance; current-head CI is still pending. See the updated description for exact manual steps.
|
🤖 Intended scope: this prompt is for people who already use a Buzz identity on a self-hosted community and are joining a Builderlab-hosted community for the first time. Someone who has never used a self-hosted community and signs up through Builderlab shouldn't get the extra step. Right now new identities get it too (the description says this is on purpose: "also covers newly generated identities", and Suggestion: rather than guessing from saved relay domains, decide based on where the key came from. If |
|
🤖 A few more notes on the implementation (none of them blocking):
|
wpfleger96
left a comment
There was a problem hiding this comment.
🤖 Blocking — two correctness findings at 28929613. The gate itself is sound: it sits in front of the only upload call site (hosted_flow.dart:637), the approval tuple binds subject + pubkey + generation, and stale, disposed, back, restart and replacement callbacks all fall through to a fresh prompt. The two problems are about what happens around it.
1. Declining leaves the identity choice permanently committed (blocking).
chooseIdentity(A) → _continue → HostedIdentityService.resolve(selectedPubkey: A) adopts A into the subject's create-only slot (hosted_identity_service.dart:161-173 → deviceIdentities.adopt / store.adoptSaved → AccountIdentityStore.create) before the new prompt appears. "Keep key on this device" calls leave() (hosted_flow.dart:985-999), which clears the target and intent but not that slot, and the store has no way to release it (create throws occupied for a different key, account_identity_store.dart:144-145). On re-entry, resolve returns the stored A at hosted_identity_service.dart:116-129, before the chooser is built.
Failure: with saved keys A and B, choose A, decline, start again. A is presented again and B can never be selected for this account, even though nothing was approved, bound or uploaded. createIdentity() and importIdentity() also write the key before the prompt, so declining after "Create my Buzz identity" (for example to import an existing nsec instead) locks in the generated key the same way. This confirms the inline finding at hosted_flow.dart:592.
Fix: show the disclosure before the irreversible local write (selection, generation, import), or keep the choice tentative until consent commits it. Extend hosted_backup_consent_test.dart with A → decline → re-enter → select B, plus a restart before selecting B; the current journey uses one saved key, so it can't catch this.
2. Browser-initiated binding skips the gate, which contradicts the stated contract (blocking unless narrowed).
The whole gate sits inside if (target is! NostrBindDeepLink) (hosted_flow.dart:547). The NostrBindDeepLink branch accepts needsBinding / needsBackup and calls WebConnectBindingService().complete → _verifyBinding directly (hosted_flow.dart:660-675, web_connect_binding.dart:81-87). No backup is uploaded on this path. But the new comment ("Ask before permanent binding as well as export") and the description ("cancellation also prevents the permanent account binding") don't hold here: a key pinned by finding 1 after a decline can be bound to the account through a browser bind link with no disclosure.
Fix, either: (a) move the consent check before both binding callers, with bind-only wording for this route, and add a real-flow browser-bind journey asserting no /verify before consent and none after decline; or (b) if a browser-initiated bind is meant to be its own consent, say so in the comment and narrow the description's binding claim.
Optional
- The last sentence of the disclosure ("keep using your self-hosted communities") doesn't make sense to someone who just generated a key in this flow. Neutral wording like "Cancel to stop setup without uploading this key" works for every entry point.
- "Keep key on this device" reads as "continue without backup", but it calls
leave(), which drops the retained invite or community intent with no message. A label like "Cancel setup", plus a line saying joining needs the backup, would match what it does.
Scores: Minimalism 9, Elegance 9, Correctness 7 (findings 1 and 2).
|
Hi @wesbillman, I have resolved the merge conflicts with the latest
A clean, zero-conflict branch is available at: You can pull it directly into your branch: git fetch https://github.com/presentyourlove/buzz.git fix/mobile-backup-consent-8823
git merge --ff-only FETCH_HEAD |
jedwards27
left a comment
There was a problem hiding this comment.
🤖 :bot: Jude’s code review agent
Verdict: REQUEST CHANGES
Reviewed: aa5f3e7bde9cf4412d6494e6a89a0d49e9a6a8f2..28929613ff0395d20d2e46e9812f06bf80dbba88
Risk: critical — consent, key export, and identity/account binding.
Blocking findings
-
[P2] Declining backup permanently reserves the selected local identity.
mobile/lib/shared/hosted/hosted_flow.dart:538–542,570–591callsresolvebefore displaying the new prompt. The resolver adopts the selected key into a create-only subject slot (hosted_identity_service.dart:145–173;account_identity_store.dart:135–161), whileleave()does not release it (hosted_flow.dart:985–999). With saved keys A and B, choose A, decline, then reconnect: the resolver returns A before presenting the chooser, so B is no longer selectable, including after restart. This independently confirms the existing inline review. Author action: keep the selection tentative until approval, or safely release only a provably uncommitted same-attempt selection without deleting a possibly committed key. Add a real-flow A → decline/back → restart/re-enter → choose B regression. -
[P2] The person cannot identify the account receiving the secret.
mobile/lib/features/onboarding/hosted_connect_sheet.dart:92–98,152–166shows the public key and service host, but only says “your Builderlab account.” An existing account session may be restored without a fresh sign-in (hosted_flow.dart:428–435), and the displayed screen gives no recognizable account identifier. Someone using an earlier, unintended account cannot recognize the recipient before granting it recovery access. Author action: let the person verify the receiving account using a verified, recognizable identifier or equivalent reliable account-confirmation flow, with a way to cancel or change account; cover a restored-wrong-account journey. An opaque internal subject alone is not a human-readable identifier. -
[P2] A retry can imply prior binding and export have not happened. Once
/verifysucceeds,/nsec-backupscan be accepted but its response lost; the missing receipt causes the same prompt to reappear (hosted_flow.dart:570–590,hosted_backup_service.dart:128–155). The prompt says “Continuing links” and offers “Keep key on this device” (hosted_connect_sheet.dart:92–98,163–166), although the binding already exists and the cloud may already have a copy. Declining cannot undo either. Author action: communicate the known/uncertain prior state and that cancelling stops future attempts rather than reversing previous effects; cover accepted upload → lost response → retry → decline → restart. -
[P2] New binding’s permanence is not disclosed on every route. The confirmation says only “links this identity”; the separate chooser warns that the app cannot change the selected identity later (
hosted_connect_sheet.dart:92–108), but create/import routes can reach confirmation without that chooser (hosted_journeys_test.dart:97–132). Author action: explain the no-in-app-change consequence before a new binding is approved, including create and import journeys.
Scope / verification: A recovered receipt also skips this gate before an unbound account is bound, and NostrBindDeepLink bypasses it without uploading a backup (hosted_flow.dart:547,660–675). The PR expressly says confirmed backups skip the prompt and limits its cancellation guarantee to identities without a confirmed backup; the browser’s separate disclosure was not inspected. These are scope/verification questions, not asserted additional blockers for this backup-consent PR.
Validation: source tracing and git diff --check aa5f3e7bde9cf4412d6494e6a89a0d49e9a6a8f2..HEAD passed with git rev-parse HEAD reporting the reviewed head and a clean worktree. GitHub reports the Mobile Checks, three Mobile Tests shards, Android, and aggregate Mobile checks successful; it also reports merge conflicts with current base. These reported CI results do not prove the missing journeys above. No independent Flutter suite, native app journey, screen-reader/text-scale or mutation run was completed: the isolated reviewers' Hermit/Flutter and simulator checks timed out. No production credentials or key material were used.
Verification owner: author for the concrete fixes and regression journeys; reviewer/tooling for independent native and accessibility verification. Residual risk: the external browser-binding disclosure and exact native user experience remain unverified. Merge-conflict resolution is a separate prerequisite, and any new head needs review.
Merge current main and address cancellation, receiving-account disclosure, uncertain uploads, and browser binding consent. Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> Signed-off-by: Wes <[email protected]>
Integrate #9394 without changing the reviewed mobile implementation. Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> Signed-off-by: Wes <[email protected]>
Integrate main through #9418 to repair workflow test compilation and runner compatibility. Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> Signed-off-by: Wes <[email protected]>
wesbillman
left a comment
There was a problem hiding this comment.
🤖 Carl, an automated reviewer, commenting via Wes's GitHub account.
Verdict: no blocking defects at dfb29fe5e7 (base ce42853982). This was an independent Court review: a security and state-machine trace, a headless mutation run, and a UX and accessibility pass. The earlier blockers hold at source. No key is written to the account slot before consent. Imported keys stay tentative. The browser bind route asks before /verify. Approval is pinned to subject, pubkey and generation.
Validation: 95/95 touched tests pass with the public hosted defines. Three temporary journeys also passed: A → cancel → restart → B; paired → decline → slot empty; and a browser bind with a saved but unconfirmed slot. These mutations were each caught: early adoption, stale approval, and early imported-nsec persistence. Removing the bind-only gate stayed green across all 95 tests (inline). At 320×568 with 2× text the layout has no overflow and all actions are reachable. Native-device and human acceptance remain unrecorded.
Before merge (recommended): add the bind-gate journey step so that gate is falsifiable. The other inline notes are optional. Also optional: the chooser copy (hosted_connect_sheet.dart:117) still says the selection "will be bound… cannot change later", but the choice can now be cancelled at the next step.
| find.text('Receiving account: [email protected]'), | ||
| findsOneWidget, | ||
| ); | ||
| expect(await f.store.read(), isNull); |
There was a problem hiding this comment.
🤖 Test gap (recommended before merge): no test fails if the bind-only consent gate is removed.
Mongo disabled the target is NostrBindDeepLink && !await _allowBackup(...) branch (hosted_flow.dart:648), and all 95 touched tests stayed green. This journey imports into an empty slot, so the pre-adoption needsConsent stops it first and hides the gate.
A temporary journey with a saved account slot without a receipt (needsBinding, not adopted in this attempt), opened through the browser bind link, does catch it: the mutated code sends /verify before approval. This is the route Will's review blocked on, so per AGENTS.md rule 3 it deserves a falsifiable step. Add that state as one more step here (seed the slot, open the bind link, expect Link your identity?, assert no /verify until Link and continue, and none after Cancel setup).
There was a problem hiding this comment.
🤖 Wes’s AI agent (Brain): Addressed in ac9ac9b. Included in current head 1acfd2d after the required main refresh. Extended this existing journey with a returning account whose key is already saved without a receipt: no /verify before approval or after cancellation, preserved key across restart, then binding only after Link and continue, with no backup upload. Both consent journeys pass. In an isolated source/Git/build copy, disabling only the bind gate now fails the new consent-prompt assertion; restoring the guard passes. Production code is unchanged. Required hooks passed; updated-head CI is pending.
| 'This app cannot change the linked identity later. ' | ||
| 'No backup is uploaded by this browser handoff. ' | ||
| 'Cancelling stops setup; it does not undo any earlier linking.' | ||
| : 'Allow Builderlab to link this identity to your account and upload ' |
There was a problem hiding this comment.
🤖 Optional: copy for each state. The sheet shows this one text for every non-bind state, because _presentBackupConsent doesn't receive the resolved state. Two cases read wrong:
- Already linked, backup unfinished (
needsBackup). Example: a fresh key bound, then the upload was interrupted, then Retry or restart. The person sees "Allow Builderlab to link this identity" for a key the account is already bound to, and one they never chose. Re-asking here is the documented contract and isn't the issue. The wording is. Something like "This identity is already linked to {email}; its backup didn't finish…" would fit. Cancelling also can't lead anywhere else for that account, so the person may need to be told that. - First-time pick of saved key A. It gets "including an upload whose confirmation was lost" when nothing happened before. Conditional wording ("If an earlier attempt…") or passing the state through avoids implying a prior upload.
| ), | ||
| const SizedBox(height: Grid.twelve), | ||
| SelectableText( | ||
| fullNpub(flow.backupPubkey ?? '') ?? '', |
There was a problem hiding this comment.
🤖 Optional (accessibility and recognisability): the chooser labels keys ("Self hosted", "Second"), but this screen shows only an unlabelled 63-character npub. With two saved keys, the person can't easily confirm they're approving the one they picked, and a screen reader reads the npub with no context. Consider an Identity: {choice label} line, with the npub as secondary text or in its semantics label.
Related: when /v1/auth/me returns no email, the advice "Sign out and sign in again" won't help if the server never supplies one for that account. Either name the real blocker or fall back to another identifier the person can recognise.
| Set<String> offered = const {}, | ||
| String? importNsec, | ||
| }) => _resolve(isCurrent, selectedPubkey, createNew, offered, importNsec); | ||
| bool Function(String pubkey)? canAdopt, |
There was a problem hiding this comment.
🤖 Optional hardening: canAdopt is nullable, and null allows adoption. The two flow resolutions deny unapproved keys explicitly, but the post-bind identity.resolve(isCurrent: ...) at hosted_flow.dart:727 passes none. It's safe today only because the slot is already occupied by then. Mordecai traced every supported path and found no reachable bypass. Making it required, or deny-by-default, would remove the implicit exception so a future caller can't skip consent by omission.
Extend the browser journey through a persisted unconfirmed account key, cancellation, restart and explicit binding approval. Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> Signed-off-by: Wes <[email protected]>
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> Signed-off-by: Wes <[email protected]>
kalvinnchau
left a comment
There was a problem hiding this comment.
🤖 One non-blocking test coverage improvement; no new production defect found.
| expect((await f.storage.loadAll()).first.nsec, f.keys.nsec); | ||
| expect(await f.storage.loadActiveId(), community.id); | ||
| expect(await f.store.read(), isNull); | ||
|
|
There was a problem hiding this comment.
🤖 [P3] Exercise stale approval on the same flow and same key
The same-owner replay here presents a different key, so _pendingBackup != approved rejects it. The same-key replay happens after f.restart(), which creates a new flow, so _disposed rejects it. Removing presentedGeneration != _generation from hosted_flow_backup_consent.dart leaves both consent journeys passing.
Add a same-mounted-flow cancellation and re-presentation of the same key/account, invoke the old confirmation callback, and assert no /verify or /nsec-backups request. This would protect the generation guard independently, including a bind-only to backup-upload transition. This is a non-blocking coverage gap; the production guard is present and no reachable production bypass was demonstrated.
Co-authored-by: mboston.Engineer <932d3b6ebea6cc83e091551ae46d1c7e7f8f7d0fa8e9cef5e6facc8544af413b@buzz.block.builderlab.xyz> * origin/main: fix(mobile): require Builderlab consent for existing identities (#8823) feat(mobile): use HTTP/2 on iOS through app-wide URLSessions (#8897) fix(relay): publish a git push only when refs changed; check the protocol by refinement (#9425) fix(mobile): show shared chats under Solo (#9348) chore(release): release Buzz Desktop version 0.5.29 (#9456) feat(mobile): render messages with gpt_markdown 1.3.4 plusparse (#8945) fix(acp): bound observer retention by bytes (#7987) (#9436) Tune release codegen for faster/smaller binaries (#9434) feat(desktop): add dismissible banner pointing to the new buzz app (#9379) fix(mobile): keep publishing presence while the app is open (#9441) feat(mobile): share into Buzz from the iOS share sheet (#9433) fix(mobile): qualify every namesake mention and keep authored mention casing (#9325) fix(mobile): match browser GIF timing in the iOS reaction sheet (#9437) fix(mobile): play short-delay animation frames at browser speed (#9432) buzz-search: read profile names without the profile_name_fields function (#9397) buzz-db: build the community deletion lock key in Rust (#9396) fix(cli): pair for the active community, skip stopped desktops, accept a bare relay host (#9417) test(mobile): run push tests without a gateway dart-define (#9423) fix(mobile): never pop Home when archiving from channel settings (#9387) perf(acp): reduce observer CPU and lock overhead (#9381) Signed-off-by: Matthew Boston <[email protected]>
…count-backfill Co-authored-by: mboston.Engineer <932d3b6ebea6cc83e091551ae46d1c7e7f8f7d0fa8e9cef5e6facc8544af413b@buzz.block.builderlab.xyz> * bostonaholic--agent-reply-count: fix(mobile): require Builderlab consent for existing identities (#8823) feat(mobile): use HTTP/2 on iOS through app-wide URLSessions (#8897) fix(relay): publish a git push only when refs changed; check the protocol by refinement (#9425) fix(mobile): show shared chats under Solo (#9348) chore(release): release Buzz Desktop version 0.5.29 (#9456) feat(mobile): render messages with gpt_markdown 1.3.4 plusparse (#8945) fix(acp): bound observer retention by bytes (#7987) (#9436) Tune release codegen for faster/smaller binaries (#9434) feat(desktop): add dismissible banner pointing to the new buzz app (#9379) fix(mobile): keep publishing presence while the app is open (#9441) feat(mobile): share into Buzz from the iOS share sheet (#9433) fix(mobile): qualify every namesake mention and keep authored mention casing (#9325) fix(mobile): match browser GIF timing in the iOS reaction sheet (#9437) fix(mobile): play short-delay animation frames at browser speed (#9432) buzz-search: read profile names without the profile_name_fields function (#9397) buzz-db: build the community deletion lock key in Rust (#9396) fix(cli): pair for the active community, skip stopped desktops, accept a bare relay host (#9417) test(mobile): run push tests without a gateway dart-define (#9423) fix(mobile): never pop Home when archiving from channel settings (#9387) perf(acp): reduce observer CPU and lock overhead (#9381) Signed-off-by: Matthew Boston <[email protected]>
Existing mobile identities entering the Builderlab hosted flow now require confirmation before the app reserves the account’s identity slot, binds the identity, or uploads its secret key. Cancelling leaves saved community keys usable and allows another identity to be selected, including after restart. Imported keys remain tentative until approval; paired keys remain safely retained outside the account slot.
The confirmation shows the receiving account’s verified email and public key, explains that Builderlab can decrypt the backup and signed-in account users can recover it, and discloses that the app cannot change the linked identity later. Cancel setup stops future work without claiming to undo earlier binding or an upload whose acknowledgement was lost. The user can sign out; missing account identification blocks confirmation. Browser-initiated binding uses Link and continue, with explicit bind-only wording and no upload.
Fresh keys generated in the current hosted attempt use ordinary onboarding without an extra backup prompt. Interrupted attempts without a confirmed backup ask again; approval is deliberately not persisted or carried across retries, cancellation, restart, account changes, or a replaced flow. Confirmed matching backup receipts skip the backup prompt. Cloud recovery and organization-managed backup are separate flows and are not changed here; this PR does not resolve server-side consent or recovery-authorization policy.
Updated against main through
ce42853982(#9418). Integration commitdfb29fe5e7includes the landed Hickory security fix and the shared workflow-test/runner repair. The consent implementation is unchanged from Carl’s reviewede11025ebed; the CI merge preserves its configured public consent journeys.Validation at
e11025ebed: 96 focused tests across eight files passed with public hosted domains configured; Flutter analysis passed. In an isolated source/Git/build copy, removing pre-adoption consent, bypassing the upload gate, and removing stale-confirmation checks each failed the consent journeys; restoring the code passed. Carl’s independent source review found no blockers. Mandatory hooks and hosted mobile CI passed, including both consent journeys in the configured public lane.Integration validation at
dfb29fe5e7: 99 focused tests passed; one additional fullscreen-onboarding test failed its background community-admission assertion. An isolated clean-main control atce42853982reproduces the same failure with the same expected/actual values. All consent journeys passed. Required commit and push hooks passed, including mobile analysis and the broader default mobile suite. Hosted CI is green atdfb29fe5e7(run 43320753801). Security and the previously failing Rust/workflow gates now pass. The Mobile Checks log confirms both consent journeys executed successfully; that run has no pending checks.Review follow-up
ac9ac9be35, integrated with main279a31daacat current head1acfd2dff6, is test-only: the browser journey now restores a saved account key without a receipt, cancels without binding, restarts and requires explicit approval before/verify. Both consent journeys passed again after integration using the updated CI settings. Disabling only the production bind gate in an isolated source/Git/build copy now fails the added step; restoring it passes. Mandatory commit/push hooks passed. Hosted CI is green at1acfd2dff6(run 43343333843); no failing or pending checks remain. The Mobile Checks log confirms both consent journeys executed successfully, including the extended browser-binding journey. Carl’s latest review ondfb29fe5e7found no production blockers; its recommended test gap is addressed by this follow-up.Native-device and human acceptance have not been recorded here; the author’s ready-for-review setting is preserved.
Try it: with two saved self-hosted keys, connect to Builderlab, select A, cancel, restart, then select B. Check the receiving account and key before approval. Repeat with an imported/paired key and a browser bind link; cancel must not reserve or bind the new selection. After an interrupted upload, cancellation must describe that earlier effects may remain. Confirm that ordinary brand-new onboarding has no extra backup step.