Skip to content

Passkey RP ID dual-support for ROR migration#178

Open
dangeross wants to merge 6 commits into
mainfrom
savage-fix-passkey-rpid
Open

Passkey RP ID dual-support for ROR migration#178
dangeross wants to merge 6 commits into
mainfrom
savage-fix-passkey-rpid

Conversation

@dangeross

@dangeross dangeross commented Apr 21, 2026

Copy link
Copy Markdown
Contributor

Taken over and re-ported onto main. Since the original commits, main rewrote the passkey system (the app's own PRF provider was replaced by the SDK's PasskeyClient register/signIn/labels API), so a literal rebase was not viable. This is a semantic re-port: the intent of the original four commits is preserved and re-expressed against the new API (the migration modal was carried over verbatim and only its API call sites were rewired). The original branch history is preserved in this PR's force-push timeline.

Problem

Production was deployed without VITE_PASSKEY_RP_ID set, so all existing passkeys were created with window.location.hostname as their RP ID. The intended RP ID is keys.breez.technology (a Related Origin Requests / ROR server).

WebAuthn cryptographically binds a passkey to its RP ID at creation time, and since the passkey PRF output IS the wallet seed, a different RP ID derives a completely different wallet. There is no in-place key migration: enabling ROR would orphan every existing wallet.

🔑 @dangeross: BEFORE MERGING, set VITE_PASSKEY_RP_ID to keys.breez.technology.

Solution

Dual RP ID handling (commit "Legacy RP ID handling")

  • LEGACY_RP_ID (window.location.hostname on web) for all existing passkeys; ROR_RP_ID (VITE_PASSKEY_RP_ID) for new ones when ROR is configured.
  • passkeyRpId persisted in localStorage as device metadata (survives logout, not cleared by clearPasskeyMode).
  • Existing users derive under their stored-or-legacy RP ID on resume / label switch, with backfill on first success, so enabling ROR never orphans a pre-migration wallet.
  • Because main funnels every passkey op through one module-level rpId, this adds rpId-scoped client construction (buildMigrationPasskeyClient plus a web-only rpId override on signInPinnedToActiveCredential) to replace the deleted mutable passkeyPrfProvider.setRpId().
  • Dev-only RP-ID switch in the Passkey settings hub (shown only when ROR differs from legacy).

In-app migration (commit "Passkey migration")

  • A PasskeyMigrationModal upgrades a legacy-RP user to the ROR RP ID without losing funds: it creates the ROR passkey once, then per label sweeps the full sats + token balance, the Lightning address, and contacts from the old wallet to the new one, ordering the active label last so its wallet becomes the adopted session.
  • Two entry points: an auto-opened banner after a legacy-RP reconnect, and a login-path offer from PasskeyPage when no ROR credential is found. Both are web-gated (the modal uses the browser passkey client; native keeps its fixed RP ID).
  • The new ROR credential is recorded as active only on successful switch, so a mid-migration failure never points resume at an unusable credential. Unclaimed deposits across any label block migration until claimed.

Atomic Lightning Address transfer (commit "Atomic Lightning Address transfer")

  • The migration's Lightning-address step now uses the SDK's atomic, symmetric two-signature transfer (spark-sdk #829): old.authorizeLightningAddressTransfer({ transfereePubkey }) then newSdk.claimLightningAddressTransfer({ authorization, description }).
  • This replaces the prior delete-then-register step, which had a window where the username was unregistered and could be lost. The SDK rejects a self-transfer, already guarded by the in-flow identity assertion.

Review fixes (commit "Fix login-entry passkey migration ...")

A multi-agent adversarial review found the fund-moving, credential-derivation, and state-machine logic clean, and caught two login-path regressions from adapting to main's refactored PasskeyPage, both fixed here:

  • Migration is offered on any web detect failure (web has no deterministic no-credential signal, so the previous gate left fresh-browser legacy users unable to migrate).
  • Cancelling a login-entry migration navigates home instead of stranding the page on an indefinite "Detecting passkey..." spinner.

⚠️ Merge blocker: SDK

Bumps the vendored SDK to 0.16.1-dev1, built locally from spark-sdk main (commit e4d5f80, which contains #829). The only app-visible WASM API delta versus the prior 0.16.0-dev3 is the two transfer methods plus their request/response types, so the bump is additive and drop-in. Before merge, this needs the SDK officially released with #829 and package.json pointed at the released version.

Known limitations

  • Not yet end-to-end tested with live WebAuthn on a ROR-enabled deployment (needs a funded legacy-RP wallet). Verified via type-check, build, browser boot, and the adversarial review.
  • If a post-migration user fully wipes localStorage, detection defaults to the legacy RP ID and will not find their ROR passkey; they would use the dev RP-ID switch to recover. Accepted as a rare edge case.

@vercel

vercel Bot commented Apr 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
breez-glow-web Ready Ready Preview, Comment Jun 8, 2026 1:22pm
breez-sdk-spark-example Ready Ready Preview, Comment Jun 8, 2026 1:22pm
savage-glow-web Ready Ready Preview, Comment Jun 8, 2026 1:22pm

Upgrade your passkey
</div>
<div className="text-xs text-spark-text-secondary">
Move to the new passkey system — we'll transfer your funds automatically.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we'll transfer your funds automatically.

This is not implemented yet, is it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

working on it now

@dangeross dangeross force-pushed the savage-fix-passkey-rpid branch from 147bdd2 to d431d52 Compare April 22, 2026 12:50
@dangeross dangeross force-pushed the savage-fix-passkey-rpid branch from d431d52 to 6f200bc Compare April 22, 2026 13:17
@dangeross dangeross force-pushed the savage-fix-passkey-rpid branch from 6f200bc to 2783a6b Compare April 22, 2026 15:01
@dangeross dangeross force-pushed the savage-fix-passkey-rpid branch from 2783a6b to f0d4776 Compare April 22, 2026 15:13
@dangeross dangeross force-pushed the savage-fix-passkey-rpid branch from f0d4776 to 22c09ca Compare April 22, 2026 15:33
@erdemyerebasmaz

Copy link
Copy Markdown
Contributor

Re-port of the original savage-fix-passkey-rpid RP-ID work onto main's
SDK-PasskeyClient passkey architecture.

Introduce a legacy-vs-ROR Relying Party ID split (LEGACY_RP_ID / ROR_RP_ID)
so enabling Related Origin Requests via VITE_PASSKEY_RP_ID can't orphan
existing passkey wallets, whose credentials are bound to the old hostname
RP ID. Existing users now derive under their stored RP ID, defaulting to
legacy; the resolved value is persisted on first sign-in (backfill) and
reused on resume and label switch.

Since main funnels every passkey op through a single module-level rpId
const, add rpId-scoped web client construction (buildBrowserPasskeyClient
gains an rpId option; signInPinnedToActiveCredential gains a web-only
rpId override) plus buildMigrationPasskeyClient as the foundation for the
migration flow. Adds passkeyRpId persistence helpers and a dev-only RP-ID
switch in the Passkey settings hub.
Re-port of the original migration flow (commits 974c93c + d58257d) onto
main's SDK-PasskeyClient architecture. The original single-label modal
was rewritten ~90% by the multi-label commit, so the final multi-label
modal is ported directly in one commit (origin/savage-fix-passkey-rpid
preserves the single->multi evolution).

Lets a legacy-RP user upgrade to the ROR RP ID without losing funds: it
creates the ROR passkey, then per label sweeps the sats + token balances,
Lightning address, and contacts from the old wallet to the new one,
ordering the active label last so its wallet becomes the adopted session.

Adaptation to main's API:
- The deleted mutable passkeyPrfProvider.setRpId(...) pattern is replaced
  by rpId-scoped PasskeyClients (buildMigrationPasskeyClient): a LEGACY
  client derives the old wallet, a ROR client the new one. createPasskey
  becomes register(); getWallet becomes signIn().wallet; saveLabel becomes
  labels().store(). allowCredentials is pinned per RP to guard multi-cred
  devices.
- The ROR credential is recorded as the active credential only on
  successful switch (recordMigratedRorCredential), so a mid-migration
  failure never points resume at an unusable credential.
- useBreezSdk gains needsPasskeyMigration (set after a legacy-RP passkey
  connect) and adoptMigratedSdk (adopts the new SDK, keeping
  mnemonic/ticker/network state); the paymentSucceeded celebration is
  suppressed during sweeps.
- App auto-opens the banner on a pending legacy connect and exposes
  requestMigrationCheck to PasskeyPage, which offers migration when no ROR
  credential is found for a new-to-device user.

LN-address transfer is kept as delete-then-register here; it is swapped
for the SDK's atomic authorize/claim transfer in a follow-up commit.
Addresses two medium-severity regressions found by the adversarial review
of the re-port (both in the PasskeyPage login/new-device path; the banner
path and the fund-moving/credential logic were clean):

- Restore navigation home on a 'handled' migration outcome. The modal
  reports 'handled' on both success and cancel; on cancel App only closes
  the modal without navigating, so the detect effect's bare `return` left
  the page stranded on a perpetual "Detecting passkey..." spinner. The
  original called onBack() here; reintroduce it via a useLatest onBackRef.

- Offer migration on any web detect failure, not just `looksLikeNewUser`.
  On web there is no deterministic no-credential signal and dismissing the
  empty ROR picker is a slow cancel, so the old gate was effectively dead
  and a fresh-browser legacy-RP user could never reach migration (the
  banner can't cover them either). Matches the original's unconditional
  offer.

- Web-gate both migration entry points (the PasskeyPage offer and
  useBreezSdk's needsPasskeyMigration): the migration modal uses the
  browser passkey client, so native (fixed RP ID) must never enter it.
  This also fixes a latent bug where a native fast-no-cred would have
  opened the browser-only modal.
Phase 2 of the savage-fix-passkey-rpid re-port. Replaces the migration's
best-effort delete-then-register Lightning-address step with the SDK's
atomic, symmetric two-signature transfer (spark-sdk #829):

  const authorization = await old.authorizeLightningAddressTransfer({
    transfereePubkey: newInfo.identityPubkey,
  });
  await newSdk.claimLightningAddressTransfer({ authorization, description });

The old wallet (current owner) signs the handover to the new wallet's
identity pubkey and the new wallet claims it, so the username is never
unregistered mid-transfer (the delete-then-register path had a window
where it could be orphaned). The SDK rejects a self-transfer, already
guarded by the in-flow identity assertion. Both SDKs are connected during
the sweep, so this slots into the existing best-effort step unchanged.

Bumps the vendored SDK to 0.16.1-dev1, built from spark-sdk origin/main
(e4d5f80, which contains #829). The only app-visible WASM API delta from
the previous vendored 0.16.0-dev3 is the two transfer methods + their
request/response types, so the bump is additive and drop-in.
The 1054-line PasskeyMigrationModal mixed fund-moving business logic into
a React component, making it hard to review. Split it into the repo's
standard phased-flow shape (mirrors features/send):

- src/services/passkeyMigrationService.ts: React-free SDK orchestration
  (connectForSeed, sweepBalances, transferLightningAddress, migrateContacts,
  captureStableTicker, ...) + createMigrationSession() encapsulating the
  rpId-scoped legacy/ROR PasskeyClients and credential pinning. Testable in
  isolation from the UI.
- src/features/passkey-migration/hooks/useMigrationFlow.ts: the phase state
  machine, per-phase effects (thin: call the service, guard cancellation,
  transition), SDK lifecycle + cleanup, and user actions.
- src/features/passkey-migration/PasskeyMigrationModal.tsx: thin container.
- src/features/passkey-migration/steps/*: pure per-phase UI components.

No behavior change: identical phases, prompts, cancellation, SDK ownership,
resume-safety, and the atomic Lightning-address transfer. Reuses the shared
formatError util and uses clearer local names (oldSdk/index,
getLegacyClient/getRorClient, currentAddress, registration).

Verified: tsc + eslint (0 errors; warnings down 9 -> 3), app boots clean,
and a 5-dimension adversarial behavior-equivalence review vs the prior
monolith (9 findings raised, 0 confirmed).
Apply Vercel React best practices to the passkey-migration flow:

- async-parallel: in the per-label sweep, fetch the new wallet's synced
  info and the old wallet's info with Promise.all (independent reads on
  separate SDK instances) instead of sequentially, removing a waterfall.

- bundle / code-split: the legacy->ROR migration is a rare flow, but its
  modal + service + hook were statically imported and always-mounted, so
  they shipped in the initial bundle for every user. Load it via
  React.lazy + Suspense (matching the app's lazy pages), gated behind a
  sticky migrationEverOpened flag so the chunk loads only after the flow
  is first triggered, while keeping the modal mounted afterwards to
  preserve its isOpen-gated SDK cleanup. Confirmed: the migration modules
  are not fetched on initial page load.

No behavior change: the extra parallel read has no side effects, and the
lazy modal renders identically once loaded.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants