adding hash gate to make sure accounts match up (#1003)

This commit is contained in:
Blake Kaufman
2026-07-10 15:28:52 -04:00
committed by GitHub
parent c9f58664f7
commit e92297ab12
10 changed files with 104 additions and 32 deletions
+12 -2
View File
@@ -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,
+3
View File
@@ -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' },
);
});
@@ -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++;
@@ -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) {
+1 -19
View File
@@ -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 });
+11
View File
@@ -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),
},
},
],
});
@@ -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);
+50 -3
View File
@@ -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 (
<GlobalThemeView useStandardWidth={true}>
+2 -1
View File
@@ -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) {
+2 -1
View File
@@ -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;