From 98dead8c0a4dab2b06ff24b4b4d0fc911269936a Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Wed, 16 Apr 2025 15:42:02 +0200 Subject: [PATCH] Add vault update check to main layout scaffolding (#771) --- .../entrypoints/popup/context/AuthContext.tsx | 4 +- mobile-app/app/_layout.tsx | 100 +++++++++++++++++- mobile-app/context/AuthContext.tsx | 45 ++++---- mobile-app/context/DbContext.tsx | 27 ++--- 4 files changed, 133 insertions(+), 43 deletions(-) diff --git a/browser-extension/src/entrypoints/popup/context/AuthContext.tsx b/browser-extension/src/entrypoints/popup/context/AuthContext.tsx index a433e9b39..40ad34838 100644 --- a/browser-extension/src/entrypoints/popup/context/AuthContext.tsx +++ b/browser-extension/src/entrypoints/popup/context/AuthContext.tsx @@ -31,7 +31,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children const dbContext = useDb(); /** - * Check for tokens in chrome storage on initial load. + * Check for tokens in browser local storage on initial load. */ useEffect(() => { /** @@ -52,7 +52,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }, []); /** - * Set auth tokens in chrome storage as part of the login process. After db is initialized, the login method should be called as well. + * Set auth tokens in browser local storage as part of the login process. After db is initialized, the login method should be called as well. */ const setAuthTokens = useCallback(async (username: string, accessToken: string, refreshToken: string) : Promise => { await storage.setItem('local:username', username); diff --git a/mobile-app/app/_layout.tsx b/mobile-app/app/_layout.tsx index 225af40e4..d91c5400b 100644 --- a/mobile-app/app/_layout.tsx +++ b/mobile-app/app/_layout.tsx @@ -3,12 +3,14 @@ import { useFonts } from 'expo-font'; import { Stack } from 'expo-router'; import * as SplashScreen from 'expo-splash-screen'; import { StatusBar } from 'expo-status-bar'; -import { useEffect } from 'react'; +import { useEffect, useCallback, useRef } from 'react'; +import { AppState, AppStateStatus } from 'react-native'; import 'react-native-reanimated'; // Required for certain modules such as secure-remote-password which relies on crypto.getRandomValues // and this is not available in react-native without this polyfill import 'react-native-get-random-values'; import Toast from 'react-native-toast-message'; +import AsyncStorage from '@react-native-async-storage/async-storage'; import { useColorScheme } from '@/hooks/useColorScheme'; import { LoadingProvider } from '@/context/LoadingContext'; @@ -17,6 +19,8 @@ import { AuthProvider } from '@/context/AuthContext'; import { WebApiProvider } from '@/context/WebApiContext'; import { useAuth } from '@/context/AuthContext'; import { useDb } from '@/context/DbContext'; +import { useWebApi } from '@/context/WebApiContext'; +import { VaultResponse } from '@/utils/types/webapi/VaultResponse'; import { View, ActivityIndicator, Text } from 'react-native'; // Prevent the splash screen from auto-hiding before asset loading is complete. @@ -53,6 +57,8 @@ function RootLayoutNav() { const colorScheme = useColorScheme(); const authContext = useAuth(); const dbContext = useDb(); + const webApi = useWebApi(); + const isFirstMount = useRef(true); // Check if user is authenticated and database is available const isFullyInitialized = authContext.isInitialized && dbContext.dbInitialized; @@ -60,6 +66,98 @@ function RootLayoutNav() { const isDatabaseAvailable = dbContext.dbAvailable; const requireLoginOrUnlock = isFullyInitialized && (!isAuthenticated || !isDatabaseAvailable); + /** + * Check for vault updates and fetch if necessary + */ + const checkVaultUpdates = useCallback(async (isStartup: boolean = false) => { + const isLoggedIn = await authContext.initializeAuth(); + + if (!isLoggedIn) { + console.log('Start check vault update: Not authenticated'); + return; + } + + // TODO: add check to know if the encryption key is available in the keychain + // If not, we should redirect to the unlock screen. + // Or do this check in the "isLoggedIn" check above? + const passwordHashBase64 = await AsyncStorage.getItem('passwordHash'); + if (!passwordHashBase64) { + //console.log('Vault check error: Password hash not found'); + //await webApi.logout('Password hash not found'); + //rreturn; + } + + console.log('Start check vault updates'); + + try { + // On startup, always check. Otherwise, check the time elapsed + if (!isStartup) { + const lastCheckStr = await AsyncStorage.getItem('lastVaultCheck'); + const lastCheck = lastCheckStr ? parseInt(lastCheckStr, 10) : 0; + const now = Date.now(); + + // Only check if more than 1 hour has passed since last check + if (now - lastCheck < 3600000) { + console.log('Vault check skipped: Not enough time has passed since last check'); + return; + } + } + + console.log('Checking vault updates'); + + // Update last check time + await AsyncStorage.setItem('lastVaultCheck', Date.now().toString()); + + // Do status check to ensure this mobile app is supported and check vault revision + const statusResponse = await webApi.getStatus(); + const statusError = webApi.validateStatusResponse(statusResponse); + if (statusError !== null) { + console.log('Vault check error:', statusError); + await webApi.logout(statusError); + return; + } +r + // If the vault revision is higher, fetch the latest vault + if (statusResponse.vaultRevision > dbContext.vaultRevision) { + const vaultResponseJson = await webApi.get('Vault'); + + const vaultError = webApi.validateVaultResponse(vaultResponseJson); + if (vaultError) { + console.log('Vault check error:', vaultError); + console.log('Vault response:', vaultResponseJson); + await webApi.logout(vaultError); + return; + } + // Initialize the SQLite context again with the newly retrieved decrypted blob + await dbContext.initializeDatabase(vaultResponseJson); + } + else { + console.log('Vault check finished: Vault revision is the same, no action requiredr'); + } + } catch (err) { + console.error('Vault check error:', err); + } + }, [isAuthenticated, dbContext, webApi]); + + // Handle app state changes and initial mount + useEffect(() => { + // On first mount (cold boot), always check + if (isFirstMount.current) { + isFirstMount.current = false; + checkVaultUpdates(true); + } + + const subscription = AppState.addEventListener('change', (nextAppState: AppStateStatus) => { + if (nextAppState === 'active') { + checkVaultUpdates(false); + } + }); + + return () => { + subscription.remove(); + }; + }, [checkVaultUpdates]); + // Show loading screen while initializing if (!isFullyInitialized) { return ( diff --git a/mobile-app/context/AuthContext.tsx b/mobile-app/context/AuthContext.tsx index 8824b23fa..831985593 100644 --- a/mobile-app/context/AuthContext.tsx +++ b/mobile-app/context/AuthContext.tsx @@ -7,6 +7,7 @@ type AuthContextType = { isInitialized: boolean; username: string | null; setAuthTokens: (username: string, accessToken: string, refreshToken: string) => Promise; + initializeAuth: () => Promise; login: () => Promise; logout: (errorMessage?: string) => Promise; globalMessage: string | null; @@ -29,28 +30,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children const dbContext = useDb(); /** - * Check for tokens in chrome storage on initial load. - */ - useEffect(() => { - /** - * Initialize the authentication state. - */ - const initializeAuth = async () : Promise => { - const accessToken = await AsyncStorage.getItem('accessToken') as string; - const refreshToken = await AsyncStorage.getItem('refreshToken') as string; - const username = await AsyncStorage.getItem('username') as string; - if (accessToken && refreshToken && username) { - setUsername(username); - setIsLoggedIn(true); - } - setIsInitialized(true); - }; - - initializeAuth(); - }, []); - - /** - * Set auth tokens in chrome storage as part of the login process. After db is initialized, the login method should be called as well. + * Set auth tokens in storage as part of the login process. After db is initialized, the login method should be called as well. */ const setAuthTokens = useCallback(async (username: string, accessToken: string, refreshToken: string) : Promise => { await AsyncStorage.setItem('username', username); @@ -60,6 +40,24 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children setUsername(username); }, []); + /** + * Initialize the authentication state, called on initial load by _layout.tsx. + * @returns boolean indicating whether the user is logged in + */ + const initializeAuth = useCallback(async () : Promise => { + const accessToken = await AsyncStorage.getItem('accessToken') as string; + const refreshToken = await AsyncStorage.getItem('refreshToken') as string; + const username = await AsyncStorage.getItem('username') as string; + let isAuthenticated = false; + if (accessToken && refreshToken && username) { + setUsername(username); + setIsLoggedIn(true); + isAuthenticated = true; + } + setIsInitialized(true); + return isAuthenticated; + }, []); + /** * Set logged in status to true which refreshes the app. */ @@ -96,12 +94,13 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children isLoggedIn, isInitialized, username, + initializeAuth, setAuthTokens, login, logout, globalMessage, clearGlobalMessage, - }), [isLoggedIn, isInitialized, username, globalMessage, setAuthTokens, login, logout, clearGlobalMessage]); + }), [isLoggedIn, isInitialized, username, globalMessage, setAuthTokens, login, logout, clearGlobalMessage, initializeAuth]); return ( diff --git a/mobile-app/context/DbContext.tsx b/mobile-app/context/DbContext.tsx index a6c043dc1..9772c54a0 100644 --- a/mobile-app/context/DbContext.tsx +++ b/mobile-app/context/DbContext.tsx @@ -23,9 +23,9 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children } const credentialManager = NativeModules.CredentialManager; /** - * SQLite client. + * SQLite client is initialized in constructor as it passes SQL queries to the native module. */ - const [sqliteClient, setSqliteClient] = useState(null); + const sqliteClient = new SqliteClient(); /** * Database initialization state. If true, the database has been initialized and the dbAvailable state is correct. @@ -52,20 +52,16 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children } */ const [privateEmailDomains, setPrivateEmailDomains] = useState([]); - const initializeDatabase = useCallback(async (vaultResponse: VaultResponse, derivedKey: string) => { - // Attempt to decrypt the blob. - /*console.log('attempt to decrypt vault'); - const decryptedBlob = await EncryptionUtility.symmetricDecrypt( - vaultResponse.vault.blob, - derivedKey - );*/ + const initializeDatabase = useCallback(async (vaultResponse: VaultResponse, derivedKey: string | null = null) => { + // If the derived key is provided, store it in the keychain. + // Otherwise we assume the encryption key is already stored in the keychain. + if (derivedKey) { + await sqliteClient.storeEncryptionKey(derivedKey); + } // Initialize the SQLite client. - const client = new SqliteClient(); - await client.storeEncryptionKey(derivedKey); - await client.storeEncryptedDatabase(vaultResponse.vault.blob); + await sqliteClient.storeEncryptedDatabase(vaultResponse.vault.blob); - setSqliteClient(client); setDbInitialized(true); setDbAvailable(true); setPublicEmailDomains(vaultResponse.vault.publicEmailDomainList); @@ -83,9 +79,6 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children } try { const isVaultInitialized = credentialManager.isVaultInitialized(); if (isVaultInitialized) { - // TODO: sqlclient can be initialized in constructor instead? - const client = new SqliteClient(); - setSqliteClient(client); setDbInitialized(true); setDbAvailable(true); } else { @@ -97,7 +90,7 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children } setDbInitialized(true); setDbAvailable(false); } - + /*try { const response = await sendMessage('GET_VAULT', {}, 'background') as messageVaultResponse; if (response?.vault) {