Update CBS Mobile App codebase

This commit is contained in:
Abin Toppo
2026-09-21 13:21:25 +05:30
parent 58af541536
commit 2dcb10775c
339 changed files with 26750 additions and 16767 deletions
+85
View File
@@ -0,0 +1,85 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { featureRemoteConfig } from '../../features/featureRemoteConfig';
import { mpinCrypto } from '../security/mpinCrypto';
import { sessionManager } from '../security/sessionManager';
import { secureStorage } from './secureStorage';
/**
* The only AsyncStorage keys that survive a sign-out.
*
* The policy is deliberately inverted: everything is cleared unless it is named
* here, rather than a list of things to clear. A key added next month is then
* wiped by default instead of quietly outliving the customer who created it —
* which is the whole point of the guarantee, and the failure mode a
* clear-this-list approach has every time someone forgets to update it.
*
* None of the three exemptions is a property of a person:
*
* - the theme is a display choice made on this device, and flipping someone
* back to light mode because they signed out is a bug, not privacy;
* - the feature overrides are a tester's switches, and `featureOverrides` has
* always documented that they outlive a sign-out. They are configuration,
* and a bank kill switch still overrides anything set there;
* - the cached feature config is that kill switch. It is cached so a handset
* that starts offline still honours a feature the bank killed during an
* incident, and deleting it would make signing out a way to turn those
* features back on. Its one customer-scoped half, `roleOverrides`, is
* stripped out below instead — see `forgetRoleScope`.
*/
const KEPT_KEYS: readonly string[] = [
'@bankcbs/theme-mode',
'bankcbs.featureOverrides',
'bankcbs.featureConfig.lastKnown',
];
/**
* Removes everything this install holds about the person who was signed in.
*
* What it covers, and why each one had to be named:
*
* - the Keychain session, the remembered profile (their mobile number, org and
* name) and the **MPIN itself**, which biometric sign-in stores behind the
* device prompt. Only the session was being cleared before, so the other
* two survived every sign-out;
* - every AsyncStorage key but the three above — the biometric flag, payee
* favourites, and awareness and scheme bookmarks;
* - the customer's entitlements, in memory *and* in the config cache, which
* would otherwise be re-applied over whoever signs in next;
* - the in-memory caches, which no amount of storage clearing reaches: the
* session window and the fetched RSA key.
*
* What it deliberately does not touch is the device id. That is this install's
* identity rather than the customer's — the profile is bound to it at
* verify-otp, and every later sign-in has to send the same value or the server
* answers `DEVICE_NOT_BOUND`. Clearing it would not protect anyone; it would
* lock the handset out of the account it is registered to.
* `deviceIdentity.reset()` exists for the deliberate "register again" case.
*
* Nothing here may throw. A sign-out that fails halfway is worse than one that
* skips a key, so each step is isolated and the rest still runs.
*/
export const purgeUserData = async () => {
await Promise.allSettled([
secureStorage.clearSession(),
secureStorage.clearLastProfile(),
secureStorage.clearBiometricMpin(),
clearAsyncStorage(),
]);
// After the sweep, not beside it: it rewrites a key the sweep deliberately
// keeps, and the two racing would decide the file's contents by luck.
await featureRemoteConfig.forgetRoleScope().catch(() => {});
sessionManager.clear();
mpinCrypto.invalidate();
};
const clearAsyncStorage = async () => {
const keys = await AsyncStorage.getAllKeys();
const doomed = keys.filter((key) => !KEPT_KEYS.includes(key));
if (doomed.length > 0) {
await AsyncStorage.multiRemove(doomed);
}
};
+130 -11
View File
@@ -1,45 +1,164 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as Keychain from 'react-native-keychain';
import type { AuthTokens } from '../../types/app';
import type { AuthSession } from '../../types/app';
const BIOMETRIC_KEY = 'bankcbs.biometricEnabled';
const SESSION_SERVICE = 'com.bankcbs.mobile.session';
/** The number and org last signed in with, so sign-in can prefill them. */
const PROFILE_SERVICE = 'com.bankcbs.mobile.lastProfile';
/**
* The MPIN, held only when the customer opts into biometric sign-in. The API
* has no refresh token — a session can only be opened by encrypting the MPIN —
* so biometric sign-in is impossible without keeping it. It lives behind the
* device's own biometric prompt and never in AsyncStorage.
*/
const BIOMETRIC_MPIN_SERVICE = 'com.bankcbs.mobile.biometricMpin';
export interface LastProfile {
orgId: string;
mobileNumber: string;
fullName?: string;
}
/**
* The request interceptor needs the token on every single call, and a Keystore
* read costs tens of milliseconds on Android. Held in memory alongside the
* Keychain copy, which stays the durable one across launches.
*/
let cachedSession: AuthSession | null = null;
let sessionCacheLoaded = false;
export const secureStorage = {
async saveSession(tokens: AuthTokens) {
await Keychain.setGenericPassword('session', JSON.stringify(tokens), {
async saveSession(session: AuthSession) {
await Keychain.setGenericPassword('session', JSON.stringify(session), {
service: SESSION_SERVICE,
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
securityLevel: Keychain.SECURITY_LEVEL.SECURE_SOFTWARE,
});
cachedSession = session;
sessionCacheLoaded = true;
},
async getSession(): Promise<AuthTokens | null> {
async getSession(): Promise<AuthSession | null> {
if (sessionCacheLoaded) {
return cachedSession;
}
const credentials = await Keychain.getGenericPassword({
service: SESSION_SERVICE,
});
if (!credentials) {
cachedSession = null;
sessionCacheLoaded = true;
return null;
}
try {
cachedSession = JSON.parse(credentials.password) as AuthSession;
sessionCacheLoaded = true;
return cachedSession;
} catch {
// A session written by an older build of the app. Treat it as absent
// rather than crashing the bootstrap.
await this.clearSession();
return null;
}
},
async getAccessToken() {
const session = await this.getSession();
return session?.sessionToken ?? null;
},
async clearSession() {
cachedSession = null;
sessionCacheLoaded = true;
await Keychain.resetGenericPassword({
service: SESSION_SERVICE,
});
},
async saveLastProfile(profile: LastProfile) {
await Keychain.setGenericPassword('lastProfile', JSON.stringify(profile), {
service: PROFILE_SERVICE,
accessible: Keychain.ACCESSIBLE.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY,
securityLevel: Keychain.SECURITY_LEVEL.SECURE_SOFTWARE,
});
},
async getLastProfile(): Promise<LastProfile | null> {
const credentials = await Keychain.getGenericPassword({
service: PROFILE_SERVICE,
});
if (!credentials) {
return null;
}
return JSON.parse(credentials.password) as AuthTokens;
try {
return JSON.parse(credentials.password) as LastProfile;
} catch {
return null;
}
},
async getAccessToken() {
const session = await this.getSession();
return session?.accessToken ?? null;
async clearLastProfile() {
await Keychain.resetGenericPassword({ service: PROFILE_SERVICE });
},
async clearSession() {
await Keychain.resetGenericPassword({
service: SESSION_SERVICE,
/**
* Stored under the platform's biometric access control, so reading it back
* raises the fingerprint / Face ID prompt rather than the app faking one.
*/
async saveBiometricMpin(mpin: string) {
await Keychain.setGenericPassword('mpin', mpin, {
service: BIOMETRIC_MPIN_SERVICE,
accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET_OR_DEVICE_PASSCODE,
securityLevel: Keychain.SECURITY_LEVEL.SECURE_SOFTWARE,
});
},
async getBiometricMpin(promptTitle: string): Promise<string | null> {
try {
const credentials = await Keychain.getGenericPassword({
service: BIOMETRIC_MPIN_SERVICE,
authenticationPrompt: { title: promptTitle },
});
return credentials ? credentials.password : null;
} catch {
// Cancelled prompt, or no enrolled biometric any more.
return null;
}
},
async hasBiometricMpin() {
try {
const services = await Keychain.getAllGenericPasswordServices();
return services.includes(BIOMETRIC_MPIN_SERVICE);
} catch {
return false;
}
},
async clearBiometricMpin() {
await Keychain.resetGenericPassword({ service: BIOMETRIC_MPIN_SERVICE });
},
async setBiometricEnabled(value: boolean) {
await AsyncStorage.setItem(BIOMETRIC_KEY, JSON.stringify(value));
if (!value) {
await this.clearBiometricMpin();
}
},
async getBiometricEnabled() {