Passkey RP ID dual-support for ROR migration#178
Open
dangeross wants to merge 6 commits into
Open
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
erdemyerebasmaz
approved these changes
Apr 21, 2026
| Upgrade your passkey | ||
| </div> | ||
| <div className="text-xs text-spark-text-secondary"> | ||
| Move to the new passkey system — we'll transfer your funds automatically. |
Contributor
There was a problem hiding this comment.
we'll transfer your funds automatically.
This is not implemented yet, is it?
Contributor
Author
There was a problem hiding this comment.
working on it now
147bdd2 to
d431d52
Compare
d431d52 to
6f200bc
Compare
6f200bc to
2783a6b
Compare
2783a6b to
f0d4776
Compare
f0d4776 to
22c09ca
Compare
1e238c4 to
0e2b6b1
Compare
0e2b6b1 to
d58257d
Compare
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.
d58257d to
517ed06
Compare
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Production was deployed without
VITE_PASSKEY_RP_IDset, so all existing passkeys were created withwindow.location.hostnameas their RP ID. The intended RP ID iskeys.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_IDtokeys.breez.technology.Solution
Dual RP ID handling (commit "Legacy RP ID handling")
LEGACY_RP_ID(window.location.hostnameon web) for all existing passkeys;ROR_RP_ID(VITE_PASSKEY_RP_ID) for new ones when ROR is configured.passkeyRpIdpersisted in localStorage as device metadata (survives logout, not cleared byclearPasskeyMode).mainfunnels every passkey op through one module-levelrpId, this adds rpId-scoped client construction (buildMigrationPasskeyClientplus a web-only rpId override onsignInPinnedToActiveCredential) to replace the deleted mutablepasskeyPrfProvider.setRpId().In-app migration (commit "Passkey migration")
PasskeyMigrationModalupgrades 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.PasskeyPagewhen no ROR credential is found. Both are web-gated (the modal uses the browser passkey client; native keeps its fixed RP ID).Atomic Lightning Address transfer (commit "Atomic Lightning Address transfer")
old.authorizeLightningAddressTransfer({ transfereePubkey })thennewSdk.claimLightningAddressTransfer({ authorization, description }).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:Bumps the vendored SDK to
0.16.1-dev1, built locally fromspark-sdkmain(commite4d5f80, which contains #829). The only app-visible WASM API delta versus the prior0.16.0-dev3is 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 andpackage.jsonpointed at the released version.Known limitations