Add vault update check to main layout scaffolding (#771)

This commit is contained in:
Leendert de Borst
2025-04-16 15:42:02 +02:00
parent e771af4a7a
commit 98dead8c0a
4 changed files with 133 additions and 43 deletions

View File

@@ -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<void> => {
await storage.setItem('local:username', username);

View File

@@ -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<VaultResponse>('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 (

View File

@@ -7,6 +7,7 @@ type AuthContextType = {
isInitialized: boolean;
username: string | null;
setAuthTokens: (username: string, accessToken: string, refreshToken: string) => Promise<void>;
initializeAuth: () => Promise<boolean>;
login: () => Promise<void>;
logout: (errorMessage?: string) => Promise<void>;
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<void> => {
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<void> => {
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<boolean> => {
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 (
<AuthContext.Provider value={contextValue}>

View File

@@ -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<SqliteClient | null>(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<string[]>([]);
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) {