From e92297ab128c3aef754ef31f9e9826fff7b16b11 Mon Sep 17 00:00:00 2001 From: Blake Kaufman <68204898+BlakeKaufman@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:28:52 -0400 Subject: [PATCH] adding hash gate to make sure accounts match up (#1003) --- App.tsx | 14 ++++- __tests__/biometricsPage.test.js | 3 ++ .../admin/loginComponents/biometricsPage.js | 8 ++- .../admin/loginComponents/pinPage.js | 8 ++- app/screens/createAccount/home.js | 20 +------ app/screens/createAccount/keySetup/pin.js | 11 ++++ .../createAccount/restoreWallet/home.js | 13 ++++- app/screens/inAccount/loadingScreen.js | 53 +++++++++++++++++-- context-store/liquidEventContext.js | 3 +- context-store/rootstockSwapContext.js | 3 +- 10 files changed, 104 insertions(+), 32 deletions(-) diff --git a/App.tsx b/App.tsx index 14847418..0d452815 100644 --- a/App.tsx +++ b/App.tsx @@ -34,6 +34,7 @@ import { WebViewProvider } from './context-store/webViewContext'; import { Linking, Platform, NativeModules } from 'react-native'; import SplashScreen from './app/screens/splashScreen'; +import sha256Hash from './app/functions/hash'; import { GlobalContactsList } from './context-store/globalContacts'; import { CreateAccountHome } from './app/screens/createAccount'; @@ -497,11 +498,20 @@ function ResetStack(): JSX.Element | null { if (cancelled) return; - if (mnemonic.value && !parsedSettings.isSecurityEnabled) { + const isNoSecurityLogin = + mnemonic.value && !parsedSettings.isSecurityEnabled; + if (isNoSecurityLogin) { setAccountMnemonic(mnemonic.value); } - setSecuritySettings(parsedSettings); + // For the no-security path the loading screen renders directly as Home, so + // thread the intended seed's hash through its initialParams (kept out of the + // persisted security-settings object) to gate its identity derivation. + setSecuritySettings( + isNoSecurityLogin + ? { ...parsedSettings, expectedMnemonicHash: sha256Hash(mnemonic.value) } + : parsedSettings, + ); setInitSettings(prev => { return { ...prev, diff --git a/__tests__/biometricsPage.test.js b/__tests__/biometricsPage.test.js index c32ffb04..69e7f80f 100644 --- a/__tests__/biometricsPage.test.js +++ b/__tests__/biometricsPage.test.js @@ -234,6 +234,7 @@ describe('BiometricsLogin - never deadlocks (RC1)', () => { expect(mockSetAccountMnemonic).toHaveBeenCalledWith('seed words'); expect(mockNavigate.replace).toHaveBeenCalledWith( 'ConnectingToNodeLoadingScreen', + { expectedMnemonicHash: 'HASHED' }, ); }); }); @@ -252,6 +253,7 @@ describe('BiometricsLogin - auto-trigger', () => { expect(mockNavigate.replace).toHaveBeenCalledTimes(1); expect(mockNavigate.replace).toHaveBeenCalledWith( 'ConnectingToNodeLoadingScreen', + { expectedMnemonicHash: 'HASHED' }, ); }); @@ -398,6 +400,7 @@ describe('BiometricsLogin - legacy migration', () => { expect(mockSetAccountMnemonic).toHaveBeenCalledWith('LEGACY_MNEMONIC'); expect(mockNavigate.replace).toHaveBeenCalledWith( 'ConnectingToNodeLoadingScreen', + { expectedMnemonicHash: 'HASHED' }, ); }); diff --git a/app/components/admin/loginComponents/biometricsPage.js b/app/components/admin/loginComponents/biometricsPage.js index 71f9817d..6422ed0c 100644 --- a/app/components/admin/loginComponents/biometricsPage.js +++ b/app/components/admin/loginComponents/biometricsPage.js @@ -143,7 +143,9 @@ export default function BiometricsLogin() { storeData('pinHash', sha256Hash(storedPin.value)); setAccountMnemonic(savedMnemonic.value); didNavigate.current = true; - navigate.replace('ConnectingToNodeLoadingScreen'); + navigate.replace('ConnectingToNodeLoadingScreen', { + expectedMnemonicHash: sha256Hash(savedMnemonic.value), + }); } else { navigate.navigate('ConfirmActionPage', { confirmMessage: t( @@ -197,7 +199,9 @@ export default function BiometricsLogin() { if (decryptResponse) { setAccountMnemonic(decryptResponse); didNavigate.current = true; - navigate.replace('ConnectingToNodeLoadingScreen'); + navigate.replace('ConnectingToNodeLoadingScreen', { + expectedMnemonicHash: sha256Hash(decryptResponse), + }); } else { // Genuine failure/cancel — count it toward the retry limit. numRetriesBiometric.current++; diff --git a/app/components/admin/loginComponents/pinPage.js b/app/components/admin/loginComponents/pinPage.js index 22b51a90..4b0d5ffa 100644 --- a/app/components/admin/loginComponents/pinPage.js +++ b/app/components/admin/loginComponents/pinPage.js @@ -102,7 +102,9 @@ export default function PinPage() { if (migrationResponse) { setAccountMnemonic(savedMnemonic.value); didNavigate.current = true; - navigate.replace('ConnectingToNodeLoadingScreen'); + navigate.replace('ConnectingToNodeLoadingScreen', { + expectedMnemonicHash: sha256Hash(savedMnemonic.value), + }); } else navigate.navigate('ErrorScreen', { errorMessage: t('errormessages.failedToDecryptPin'), @@ -115,7 +117,9 @@ export default function PinPage() { setAccountMnemonic(mnemonicPlain); didNavigate.current = true; - navigate.replace('ConnectingToNodeLoadingScreen'); + navigate.replace('ConnectingToNodeLoadingScreen', { + expectedMnemonicHash: sha256Hash(mnemonicPlain), + }); } } else { if (loginSettings.enteredPinCount >= 7) { diff --git a/app/screens/createAccount/home.js b/app/screens/createAccount/home.js index 19bfc1e9..796ddd6f 100644 --- a/app/screens/createAccount/home.js +++ b/app/screens/createAccount/home.js @@ -13,12 +13,7 @@ import { CENTER, COLORS, SIZES } from '../../constants'; import { useTranslation } from 'react-i18next'; import { GlobalThemeView, ThemeText } from '../../functions/CustomElements'; import CustomButton from '../../functions/CustomElements/button'; -import { createAccountMnemonic } from '../../functions'; -import { - crashlyticsLogReport, - crashlyticsRecordErrorReport, -} from '../../functions/crashlyticsLogs'; -import { useKeysContext } from '../../../context-store/keys'; +import { crashlyticsLogReport } from '../../functions/crashlyticsLogs'; import { FONT, HIDDEN_OPACITY, @@ -96,7 +91,6 @@ const easeOut = Easing.out(Easing.cubic); // ─── Main ───────────────────────────────────────────────────────────────────── export default function CreateAccountHome({ navigation: { navigate } }) { const { t } = useTranslation(); - const { setAccountMnemonic } = useKeysContext(); const { screenDimensions } = useAppStatus(); // Shared values @@ -148,18 +142,6 @@ export default function CreateAccountHome({ navigation: { navigate } }) { ); }, []); - useEffect(() => { - (async () => { - try { - crashlyticsLogReport('Creating account mnemonic'); - const mnemonic = await createAccountMnemonic(); - setAccountMnemonic(mnemonic); - } catch (err) { - crashlyticsRecordErrorReport(err.message); - } - })(); - }, []); - const go = (page, nextPage) => { crashlyticsLogReport(`Navigating to ${page} from create account home`); navigate(page, { nextPage }); diff --git a/app/screens/createAccount/keySetup/pin.js b/app/screens/createAccount/keySetup/pin.js index c1bcb27b..98f2b885 100644 --- a/app/screens/createAccount/keySetup/pin.js +++ b/app/screens/createAccount/keySetup/pin.js @@ -14,6 +14,7 @@ import { storeMnemonicWithPinSecurity } from '../../../functions/handleMnemonic' import { privateKeyFromSeedWords } from '../../../functions/nostrCompatability'; import { getPublicKey } from 'nostr-tools'; import { initializeFirebase } from '../../../../db/initializeFirebase'; +import sha256Hash from '../../../functions/hash'; export default function PinPage(props) { const { accountMnemoinc } = useKeysContext(); @@ -27,6 +28,9 @@ export default function PinPage(props) { const didNavigate = useRef(null); // const fromGiftPath = props.route.params?.from === 'giftPath'; const didRestoreWallet = props.route.params?.didRestoreWallet; + // For restore this is the hash of the seed the user typed (source of truth). + // For create it's undefined and we hash the committed context seed below. + const restoreExpectedHash = props.route.params?.expectedMnemonicHash; useEffect(() => { // begin initializing firebase to speed up loading time @@ -83,6 +87,13 @@ export default function PinPage(props) { routes: [ { name: 'ConnectingToNodeLoadingScreen', + params: { + // Pin the loading screen's identity derivation to the exact seed + // just stored, so it can't derive from a stale/empty context seed + // during the navigation race. + expectedMnemonicHash: + restoreExpectedHash || sha256Hash(accountMnemoinc), + }, }, ], }); diff --git a/app/screens/createAccount/restoreWallet/home.js b/app/screens/createAccount/restoreWallet/home.js index 1e663a09..d1c1a281 100644 --- a/app/screens/createAccount/restoreWallet/home.js +++ b/app/screens/createAccount/restoreWallet/home.js @@ -14,6 +14,7 @@ import { } from '../../../constants'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import isValidMnemonic from '../../../functions/isValidMnemonic'; +import sha256Hash from '../../../functions/hash'; import { useTranslation } from 'react-i18next'; import { CustomKeyboardAvoidingView, @@ -188,8 +189,16 @@ export default function RestoreWallet({ if (!hasAccount) throw new Error(t('createAccount.restoreWallet.home.error2')); else { - setAccountMnemonic(mnemonic.join(' ')); - navigate.navigate('PinSetup', { didRestoreWallet: true }); + const restoredSeed = mnemonic.join(' '); + setAccountMnemonic(restoredSeed); + // Thread the hash of the seed the user actually entered through to the + // loading screen (via PinSetup). This is the source of truth for the + // account identity — immune to any later clobber of the shared + // accountMnemoinc context. + navigate.navigate('PinSetup', { + didRestoreWallet: true, + expectedMnemonicHash: sha256Hash(restoredSeed), + }); } } catch (err) { console.log('key validation error', err); diff --git a/app/screens/inAccount/loadingScreen.js b/app/screens/inAccount/loadingScreen.js index fde262e6..2f2abcfa 100644 --- a/app/screens/inAccount/loadingScreen.js +++ b/app/screens/inAccount/loadingScreen.js @@ -8,7 +8,11 @@ import { useGlobalContactsInfo } from '../../../context-store/globalContacts'; import { useGlobalAppData } from '../../../context-store/appData'; import { GlobalThemeView } from '../../functions/CustomElements'; import LottieView from 'lottie-react-native'; -import { StackActions, useNavigation } from '@react-navigation/native'; +import { + StackActions, + useNavigation, + useRoute, +} from '@react-navigation/native'; import { navigationRef } from '../../../navigation/navigationService'; import { useGlobalThemeContext } from '../../../context-store/theme'; import { useKeysContext } from '../../../context-store/keys'; @@ -23,6 +27,7 @@ import { useWebView } from '../../../context-store/webViewContext'; import ThemeIcon from '../../functions/CustomElements/themeIcon'; import { getCachedSparkTransactions } from '../../functions/spark'; import { deriveSparkIdentityKey } from '../../functions/gift/deriveGiftWallet'; +import sha256Hash from '../../functions/hash'; import { useAppStatus } from '../../../context-store/appStatus'; import { initializeAllDatabases } from '../../functions/initializeAllDatabases'; import openWebBrowser from '../../functions/openWebBrowser'; @@ -34,6 +39,13 @@ const mascotAnimation = require('../../assets/MOSCATWALKING.json'); export default function ConnectingToNodeLoadingScreen() { const navigate = useNavigation(); + const route = useRoute(); + // Hash of the exact seed the caller intends this account to load. Threaded in + // from every entry point (create/restore via pin.js, relogin via pin/biometric, + // no-security via App.tsx initialParams). We refuse to derive the account + // identity until the in-context accountMnemoinc hashes to this value — see the + // gate in the connect effect below. + const expectedMnemonicHash = route.params?.expectedMnemonicHash; const { toggleMasterInfoObject, masterInfoObject, @@ -190,12 +202,47 @@ export default function ConnectingToNodeLoadingScreen() { } } - if (preloadedUserData.isLoading && !preloadedUserData.data) return; if (didRunConnectionRef.current) return; + if (preloadedUserData.isLoading && !preloadedUserData.data) return; + + // ── Seed gate ───────────────────────────────────────────────────────── + // The account identity (UUID + spark identity) is derived from + // accountMnemoinc below. Under the restore/login navigation race this + // effect can run while accountMnemoinc is still empty/stale — deriving the + // identity from the wrong seed while the wallet connects with the correct + // one (real funds, wrong account). Latch the derivation only once the + // in-context seed is exactly the seed the caller intended. We do NOT set + // didRunConnectionRef here, so the effect re-runs (accountMnemoinc is a dep) + // and latches the instant the seed converges. + if (!accountMnemoinc) return; + if (expectedMnemonicHash) { + if (sha256Hash(accountMnemoinc) !== expectedMnemonicHash) return; + } + didRunConnectionRef.current = true; requestAnimationFrame(startConnectProcess); - }, [preloadedUserData, masterInfoObject]); + }, [ + preloadedUserData, + masterInfoObject, + accountMnemoinc, + expectedMnemonicHash, + ]); + + // Safety valve for the seed gate: if the in-context seed never converges to + // the expected one (should not happen once the eager restore-path generation + // is fixed), surface the recoverable error UI instead of spinning forever. + useEffect(() => { + const timer = setTimeout(() => { + if (!didRunConnectionRef.current) { + setHasError({ + title: t('screens.inAccount.loadingScreen.initErrorTitle'), + subtitle: t('screens.inAccount.loadingScreen.userSettingsError'), + }); + } + }, 30000); + return () => clearTimeout(timer); + }, [t]); return ( diff --git a/context-store/liquidEventContext.js b/context-store/liquidEventContext.js index 5b6a6ee1..54af614d 100644 --- a/context-store/liquidEventContext.js +++ b/context-store/liquidEventContext.js @@ -44,10 +44,11 @@ export function LiquidEventProvider({ children }) { useEffect(() => { if (!liquidNodeInformation.didConnectToNode) return; + if (!accountMnemoinc) return; if (initialLiquidRun.current) return; initialLiquidRun.current = true; startLiquidEventListener(6); - }, [liquidNodeInformation.didConnectToNode]); + }, [liquidNodeInformation.didConnectToNode, accountMnemoinc]); useEffect(() => { if (isInitialRender.current) { diff --git a/context-store/rootstockSwapContext.js b/context-store/rootstockSwapContext.js index 2086263e..778e2117 100644 --- a/context-store/rootstockSwapContext.js +++ b/context-store/rootstockSwapContext.js @@ -308,10 +308,11 @@ export const RootstockSwapProvider = ({ children }) => { useEffect(() => { if (!sparkInformation.identityPubKey) return; + if (!accountMnemoinc) return; if (didRunSignerCreation.current) return; didRunSignerCreation.current = true; createSigner(); - }, [sparkInformation.identityPubKey]); + }, [sparkInformation.identityPubKey, accountMnemoinc]); useEffect(() => { if (!signer) return;