Repository navigation
Conversation
|
@MrNova50 is attempting to deploy a commit to the miracle656's projects Team on Vercel. A member of the Team first needs to authorize it. |
|
@MrNova50 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
Some phones cannot make a passkey at all — a Redmi Note 11 5G answers createCredentialAsync with "no provider dependencies found" — so those users only ever get the classic keypair wallet. Spending works for it; recovery does not. Its secret lives in exactly one keychain entry, the encrypted backup strips key material by design, and there was no screen anywhere that could put it in the user's hands. Uninstalling the app destroyed the wallet silently. Settings now offers "Reveal secret key" when the stored address is a G-account, and a one-time reveal behind the device lock: identify the wallet, state the consequences, make the user acknowledge them, then authenticate. Leaving the screen or backgrounding the app destroys what was shown, so showing it again costs another unlock. The guard is address classification plus deriving the public key from the stored secret and requiring it to equal the stored address. That matters because lib/walletStore's signerSecret slot is polymorphic: for a keypair wallet it is the wallet's own key, for a smart wallet it is the PRF-derived fee-payer. A passkey wallet therefore gets a refusal and its fee-payer is never read, and a corrupted pairing gets a refusal too. lib/backup.ts and its test are untouched: the scrubber still rejects secrets, which is what keeps a cloud-synced backup safe.
45a8de2 to
d1e6c6f
Compare
|
Rebased onto current The three red checks on the previous head ( Re-verified against the new base: full mobile suite green (96 suites, 1157 tests) on this branch's code, plus a targeted rerun of The rerun workflows currently show |
What happened before
A phone that cannot create a passkey — the reported Redmi Note 11 5G answers
createCredentialAsyncwithno provider dependencies found— only ever gets the classic keypair wallet. Spending works for that wallet:spendAssettakes the classic branch when the stored address is not aC…, andrequirePasskey()no-ops when there is a signer secret and no passkey. Recovery did not:lib/backup.tsstrips private key material on purpose ("the last line of defence"), so the encrypted backup cannot carry it;veil_signer_secret).So uninstalling the app, losing the phone, or a corrupted keychain destroyed the wallet silently, with no warning anywhere in the UI.
What happens now
Settings shows a Reveal secret key row when the stored address is a G-account. Tapping it walks a fixed order, and each step gates the next:
Leaving the screen, backgrounding the app, or tapping Hide it destroys what was displayed and returns to step 3.
Files:
frontend/mobile/lib/secretExport.ts(new),frontend/mobile/app/settings/reveal-secret.tsx(new),frontend/mobile/app/(tabs)/settings.tsx(the row), two new test files,docs/MOBILE_RECOVERY.md.Which lock, and why that one
requirePasskey()is useless here — it deliberately no-ops for exactly the wallets being served (keypair wallet, no passkey). The gate isexpo-local-authentication, the same moduleapp/lock.tsxandhooks/useInactivityLock.tsuse, so "behind the app lock" means the device biometric or passcode the user already enrolled.One deliberate divergence from
app/lock.tsx. The idle lock lets a user with no screen lock through, which is right: an unanswerable lock is a door that will not open, and passing it loses nothing. A secret reveal is the opposite case — displaying a key behind a gate with nothing on the other side would tell the user their key was protected when it was never protected at all. So a device with nothing enrolled is a refusal, with the reason stated in the UI: set a device passcode first, otherwise anyone holding the phone would hold your money. That isUnlockOutcome = 'not-protected', and it is tested."Revealed once" — how I read it, and the alternative I rejected
I implemented one reveal at a time, not a durable "already exported" flag. A key is only useful if it can be written down, photographed, and re-entered after a reinstall; a permanent one-shot flag means one distraction, one failed screenshot, or one dead battery strands the funds with no way back — and the screen would then claim a recovery path it just destroyed. Re-showing costs a fresh acknowledgement and a fresh unlock every time, and nothing is displayed while either is outstanding. If the intent was a single lifetime reveal, say so and I will change it: it is a small edit, but the failure mode is irreversible, so I did not guess it in code.
The hazard that decided the design:
signerSecretis polymorphiclib/walletStore.ts'ssignerSecretslot holds the wallet's own key for a keypair wallet and the PRF-derived fee-payer for a smart wallet. A reveal that trusted the slot alone would dump a passkey wallet's fee-payer and call it a backup. SoresolveSecretExport()requires both:C…checklib/spendAsset.tsmakes), andKeypair.fromSecret(storedSecret).publicKey()equals the stored address.Everything else is a refusal with its own copy:
smart-wallet,secret-mismatch(a malformed secret also lands here — it refuses rather than throwing at the screen),no-secret,unknown-address,no-wallet. A smart-wallet refusal never reads the fee-payer, and the returned shape is asserted whole in tests, so the key is unreachable from the value, not merely hidden by the UI. The second condition is also what makes the round trip true by construction: a revealed key is always the key that derives the address shown beside it.Acceptance criteria
reveal-secret.tsx; screen tests prove the button is disabled before acknowledgement, a dismissed prompt reveals nothing, an unenrolled device reveals nothing, and the revealed value equals the stored secret exactlywalletKind(address) === 'keypair'; the screen renders a refusal with no reveal button, and the fee-payer's secret is absent from the rendered textlib/backup.tsandlib/__tests__/backup.test.tsare not in this diff. New tests assertassertNoSecretMaterialstill rejectssignerSecret/privateKey, and that a revealed wallet's own backup contains no key — encrypting metadata plus the secret throwsBackupErrorcreateTestnetWallet→ unlock → resolve →clearWalletStore→importTestnetWallet(secret)) and end-to-end at the screen level, including a migration case where the revealed key is imported over leftover smart-wallet state and the stale fee-payer stays unreachableTests
lib/__tests__/secretExport.test.ts— 20 tests__tests__/revealSecretScreen.test.tsx— 9 testsnpx tsc --noEmit -p tsconfig.typecheck.jsonclean; eslint 0 errors. No changeset needed — the mobile app is not a publishable package.The reveal introduces no new storage keys and holds no key in component state before the lock is passed; one test scans every AsyncStorage key and value after a reveal and asserts nothing was written.
Mainnet safety
Nothing here touches registration, key derivation, or signing, and no contract call is involved: this reads a keypair secret already in the keychain and displays it. A wallet's address is unchanged by revealing it. The risk I did weigh is the opposite of a stranding bug — a revealed key is a secret placed in the user's hands, so the copy states the exposure plainly, warns about clipboard and screenshot retention, and re-conceals on background.
Not verified on this machine — please treat as unconfirmed
There is no Android/Gradle/device toolchain here, so the following were simulated in Jest, not run on hardware:
expo-local-authenticationprompt on a Redmi Note 11 5G, including whatgetEnrolledLevelAsyncreports on a Mi with only a screen PIN, and whetherdisableDeviceFallback: falsebehaves as intended on that OEM;app/settings/danger.tsx, but no screenshot exists).Following
docs/MOBILE_PRF_FINDINGS.md, I am reporting these as gaps rather than claiming them. This needs one device run before it is trusted.Two related defects I deliberately did not touch
Both fall outside the acceptance criteria and would widen the diff, so they are observations, not changes:
app/settings/danger.tsxtells a keypair-wallet user that "a backup lets you restore this wallet" before deleting the wallet. For a classic wallet the encrypted backup carries no key, so that sentence is false, and this screen is the only thing that makes it fixable.app/settings/recovery.tsxlists three recovery mechanisms and reports none of them as ready for a classic wallet — correct, but it reads like a broken setup. It should point at this screen.Related issue
Closes #1041
Type of change
Component
Checklist
cargo testpasses (contracts) — not affected by this diffnpm run typecheckpasses (wallet / sdk / agent) — mobiletsconfig.typecheck.jsonis cleannpm run buildpasses (wallet / agent) — not run here, no device/Expo build toolchain; the mobile suite and typecheck are the substituteScreenshots / test output
No screenshots — see "Not verified on this machine" above. Jest output: