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
+80
View File
@@ -0,0 +1,80 @@
import 'react-native-get-random-values';
import { v4 as uuidv4 } from 'uuid';
import * as Keychain from 'react-native-keychain';
const DEVICE_SERVICE = 'com.bankcbs.mobile.deviceId';
/**
* Kept in memory as well so the many calls that need it during one flow do not
* each hit the Keystore.
*/
let cachedDeviceId: string | null = null;
let inflight: Promise<string> | null = null;
const persist = async (deviceId: string) => {
await Keychain.setGenericPassword('deviceId', deviceId, {
service: DEVICE_SERVICE,
accessible: Keychain.ACCESSIBLE.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY,
securityLevel: Keychain.SECURITY_LEVEL.SECURE_SOFTWARE,
});
};
const load = async (): Promise<string> => {
const stored = await Keychain.getGenericPassword({ service: DEVICE_SERVICE });
if (stored && stored.password) {
return stored.password;
}
const created = uuidv4();
await persist(created);
return created;
};
/**
* The profile the customer registers is bound to this value. Registration and
* every later sign-in must send the *same* one — a different value comes back
* as `DEVICE_NOT_BOUND`, which reads like a server fault but is a client bug.
* So it is generated exactly once, on first launch, and never regenerated
* except by an explicit `reset()`.
*
* `AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY` rather than `WHEN_UNLOCKED_*`: the id
* is not a secret, and sign-in must read it before the user has authenticated.
*/
export const deviceIdentity = {
async get(): Promise<string> {
if (cachedDeviceId) {
return cachedDeviceId;
}
// Concurrent callers on a cold start would otherwise each mint a UUID and
// the last write would win, silently unbinding the device.
inflight = inflight ?? load();
try {
cachedDeviceId = await inflight;
return cachedDeviceId;
} finally {
inflight = null;
}
},
/** In-memory peek for code that cannot await. Null before the first `get()`. */
peek() {
return cachedDeviceId;
},
/**
* Mints a fresh id, unbinding this install from its registered profile. Only
* for a deliberate "Register again" action — the customer will
* have to go through registration once more.
*/
async reset(): Promise<string> {
const next = uuidv4();
await persist(next);
cachedDeviceId = next;
return next;
},
};
+90
View File
@@ -0,0 +1,90 @@
import 'react-native-get-random-values';
import forge from 'node-forge';
import { v4 as uuidv4 } from 'uuid';
import { apiClient } from '../api/apiClient';
import { endpoints } from '../api/endpoints';
import type { PublicKeyResult } from '../../types/cbs';
/**
* The server's key is configured rather than generated at startup, so it
* survives restarts and can be pinned. It is only ever used as a fallback for
* when the key fetch itself fails — a developer running their own backend gets
* the fetched key, which is the safer default.
*/
export const PINNED_PUBLIC_KEY =
'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxhaxPpoewSn+NCHJuICPVmMCO9f3VXN8zMUFryUv5kcn9rCwQMF023o+xtQCljyJdDaJE805lkp9FMZRupSro9auwmGGHTP5fgX5arn5p21LjbcITBxVIh+Uvj1tNKdxhd69xbTf98LfWGK9DspbWd9ZPk6m00La13eMLFR3iBfljzyT9pkENQ5WSvtPrN3+6JS8K9DsCgj/3WbIwwWTPlIN1rXV6gQumFaGl5CVsuibom2tA9pHTjAJi80LLFWP3I7mMkCihvNZT0/eFIBf1WCiOixm4Rt7Xk+Yh05daazMWAJhav+btknQd1gV/1oLolb5uegaobKK5g+xyzA33wIDAQAB';
let cachedKey: forge.pki.rsa.PublicKey | null = null;
const importKey = (base64Der: string): forge.pki.rsa.PublicKey => {
const der = forge.util.decode64(base64Der);
const asn1 = forge.asn1.fromDer(forge.util.createBuffer(der));
return forge.pki.publicKeyFromAsn1(asn1) as forge.pki.rsa.PublicKey;
};
const fetchKey = async (): Promise<forge.pki.rsa.PublicKey> => {
try {
const response = await apiClient.get<PublicKeyResult>(endpoints.crypto.publicKey);
const publicKey = response.data?.publicKey;
if (publicKey) {
return importKey(publicKey);
}
} catch {
// Fall through to the pinned key.
}
return importKey(PINNED_PUBLIC_KEY);
};
export const mpinCrypto = {
async getPublicKey(): Promise<forge.pki.rsa.PublicKey> {
cachedKey = cachedKey ?? (await fetchKey());
return cachedKey;
},
/**
* Drop the cached key so the next encryption re-fetches it. Call this after a
* `MALFORMED_REQUEST`, which is what a stale or wrong key looks like from the
* client side — the server cannot say "your key is wrong" without leaking
* whether the profile exists.
*/
invalidate() {
cachedKey = null;
},
/**
* The MPIN never leaves the device in the clear. RSA-OAEP with the server's
* public key, and the ciphertext travels as `encryptedMpin`.
*
* Three things break integrations here, all of them silently:
*
* - MGF1 must be SHA-256, and is passed explicitly. node-forge happens to
* default it to whatever `md` is (verified against 1.4.0, `lib/pkcs1.js`),
* but Java's `OAEPWithSHA-256AndMGF1Padding` notoriously does not — it
* leaves MGF1 on SHA-1. Relying on a library default here produces
* ciphertext the server cannot decrypt, and the error says nothing useful.
* - `ts` is in *seconds*. Milliseconds read as ~55,000 years in the future
* and the request is rejected.
* - `nonce` must be fresh on every single call, or it is refused as a replay.
*/
async encryptMpin(mpin: string): Promise<string> {
const publicKey = await this.getPublicKey();
const payload = JSON.stringify({
mpin,
nonce: uuidv4(),
ts: Math.floor(Date.now() / 1000),
});
return forge.util.encode64(
publicKey.encrypt(payload, 'RSA-OAEP', {
md: forge.md.sha256.create(),
mgf1: { md: forge.md.sha256.create() },
}),
);
},
};
+149 -4
View File
@@ -1,17 +1,162 @@
import { appConfig } from '../../config/appConfig';
let lastActivityAt = Date.now();
/**
* Two independent limits, and the session dies at whichever comes first:
* an idle window the server resets on every authenticated call, and an
* absolute ceiling it never extends however active the user is.
*/
interface SessionWindow {
idleTimeoutMs: number;
/** Epoch ms of the server's absolute expiry, or null when unknown. */
absoluteExpiryAt: number | null;
}
export type SessionExpiryReason = 'idle' | 'absolute';
/**
* When we last exchanged an authenticated request with the server. This — not
* anything the user did — is what the server's idle window is measured from,
* so it is what expiry has to be judged on.
*/
let lastServerContactAt = Date.now();
/**
* When the user last touched the screen. Tracked separately because most of
* this app reads from fixtures: someone can tap around for five minutes without
* a single authenticated call, and treating that as idle would sign them out
* mid-use. Interaction cannot reset the server's timer by itself — only a call
* can — so this drives a keep-alive rather than the expiry clock.
*/
let lastInteractionAt = Date.now();
let window: SessionWindow = {
idleTimeoutMs: appConfig.security.sessionTimeoutMs,
absoluteExpiryAt: null,
};
export const sessionManager = {
/**
* Adopt the limits the server reported at sign-in. `idleTimeoutSeconds` and
* `expiresAt` come straight off the login response, so the app never has to
* guess and never drifts from what the backend will enforce.
*/
configure({
idleTimeoutSeconds,
expiresAt,
}: {
idleTimeoutSeconds?: number;
expiresAt?: string;
}) {
const absolute = expiresAt ? Date.parse(expiresAt) : NaN;
window = {
idleTimeoutMs:
typeof idleTimeoutSeconds === 'number' && idleTimeoutSeconds > 0
? idleTimeoutSeconds * 1000
: appConfig.security.sessionTimeoutMs,
absoluteExpiryAt: Number.isFinite(absolute) ? absolute : null,
};
lastServerContactAt = Date.now();
lastInteractionAt = Date.now();
},
clear() {
window = {
idleTimeoutMs: appConfig.security.sessionTimeoutMs,
absoluteExpiryAt: null,
};
lastServerContactAt = Date.now();
lastInteractionAt = Date.now();
},
/** An authenticated call completed, so the server's idle window just reset. */
touch() {
lastActivityAt = Date.now();
lastServerContactAt = Date.now();
lastInteractionAt = Date.now();
},
/**
* The user touched the screen. Deliberately does not reset the expiry clock:
* the server has not heard from us, so pretending otherwise would leave the
* app believing it is signed in while the next real request 401s. It only
* makes `shouldKeepAlive()` true, and the keep-alive call is what actually
* moves the server's window.
*/
noteInteraction() {
lastInteractionAt = Date.now();
},
/**
* Whether to spend a keep-alive call now.
*
* True only when the user has done something since we last spoke to the
* server and the idle window is over halfway gone. The halfway threshold is
* what keeps this to roughly one request per two and a half minutes instead
* of one per tap.
*/
shouldKeepAlive() {
if (lastInteractionAt <= lastServerContactAt) {
return false;
}
// Nothing to preserve if the session is already gone, and no point
// extending the idle window when the absolute cap is what will bite.
if (this.hasExpired() || this.getAbsoluteRemainingMs() <= window.idleTimeoutMs / 2) {
return false;
}
return this.getIdleDurationMs() >= window.idleTimeoutMs / 2;
},
/**
* Which limit will end the session first. The warning copy depends on it:
* against the idle window carrying on using the app helps, and against the
* absolute cap nothing does, so telling the user to "tap to stay signed in"
* would be a lie.
*/
nearestLimit(): SessionExpiryReason {
return this.getAbsoluteRemainingMs() <= this.getIdleRemainingMs() ? 'absolute' : 'idle';
},
getIdleTimeoutMs() {
return window.idleTimeoutMs;
},
getIdleDurationMs() {
return Date.now() - lastActivityAt;
return Date.now() - lastServerContactAt;
},
getIdleRemainingMs() {
return Math.max(0, window.idleTimeoutMs - this.getIdleDurationMs());
},
getAbsoluteRemainingMs() {
if (window.absoluteExpiryAt === null) {
return Number.POSITIVE_INFINITY;
}
return Math.max(0, window.absoluteExpiryAt - Date.now());
},
/** Milliseconds until the session dies, whichever limit bites first. */
getRemainingMs() {
return Math.min(this.getIdleRemainingMs(), this.getAbsoluteRemainingMs());
},
expiryReason(): SessionExpiryReason | null {
if (this.getAbsoluteRemainingMs() <= 0) {
return 'absolute';
}
if (this.getIdleRemainingMs() <= 0) {
return 'idle';
}
return null;
},
hasExpired() {
return this.getIdleDurationMs() >= appConfig.security.sessionTimeoutMs;
return this.expiryReason() !== null;
},
};