From ee6a408a1ae7065172ec0e78c394780fc8d4a783 Mon Sep 17 00:00:00 2001 From: Abin Toppo Date: Fri, 25 Sep 2026 16:02:03 +0530 Subject: [PATCH] Fetch the MPIN encryption key fresh before every encryption Co-Authored-By: Claude Opus 5.5 --- src/modules/auth/services/authApi.ts | 6 +-- src/services/security/mpinCrypto.ts | 62 ++++++++++++---------------- src/services/storage/appDataPurge.ts | 2 - 3 files changed, 29 insertions(+), 41 deletions(-) diff --git a/src/modules/auth/services/authApi.ts b/src/modules/auth/services/authApi.ts index 1327422..b72ff05 100644 --- a/src/modules/auth/services/authApi.ts +++ b/src/modules/auth/services/authApi.ts @@ -15,15 +15,15 @@ import type { /** * `MALFORMED_REQUEST` on an MPIN call is what a stale or wrong public key looks * like from the client — the server will not say more, because saying more - * would leak whether the profile exists. So drop the cached key, re-fetch, and - * try exactly once more. A second failure is a real problem worth surfacing. + * would leak whether the profile exists. The key is fetched fresh on every + * encryption, so a retry picks up a rotated key; try exactly once more. + * A second failure is a real problem worth surfacing. */ const withKeyRetry = async (call: () => Promise): Promise => { try { return await call(); } catch (error) { if (isCbsApiError(error) && error.code === 'MALFORMED_REQUEST') { - mpinCrypto.invalidate(); return call(); } diff --git a/src/services/security/mpinCrypto.ts b/src/services/security/mpinCrypto.ts index 8db738f..6c3a260 100644 --- a/src/services/security/mpinCrypto.ts +++ b/src/services/security/mpinCrypto.ts @@ -3,20 +3,11 @@ import forge from 'node-forge'; import { v4 as uuidv4 } from 'uuid'; import { apiClient } from '../api/apiClient'; +import { CbsApiError } from '../api/cbsErrors'; 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; - +/** The key arrives as Base64 X.509 SubjectPublicKeyInfo (SPKI) DER. */ const importKey = (base64Der: string): forge.pki.rsa.PublicKey => { const der = forge.util.decode64(base64Der); const asn1 = forge.asn1.fromDer(forge.util.createBuffer(der)); @@ -24,36 +15,31 @@ const importKey = (base64Der: string): forge.pki.rsa.PublicKey => { return forge.pki.publicKeyFromAsn1(asn1) as forge.pki.rsa.PublicKey; }; -const fetchKey = async (): Promise => { - try { +export const mpinCrypto = { + /** + * The server's *current* key, fetched fresh on every call — the backend + * contract requires it before each login and each fund transfer, so a + * rotated key is picked up immediately rather than failing until restart. + * + * No cache and no pinned fallback: encrypting to a key the server may no + * longer hold only turns a clear "could not fetch the key" into an opaque + * MALFORMED_REQUEST one round trip later. + */ + async getPublicKey(): Promise { + // A network failure here propagates as the usual NETWORK_ERROR / + // REQUEST_TIMEOUT from the interceptor. const response = await apiClient.get(endpoints.crypto.publicKey); const publicKey = response.data?.publicKey; - if (publicKey) { - return importKey(publicKey); + if (!publicKey) { + throw new CbsApiError({ + status: 0, + code: 'UNKNOWN_ERROR', + message: 'Could not fetch MPIN encryption key', + }); } - } catch { - // Fall through to the pinned key. - } - return importKey(PINNED_PUBLIC_KEY); -}; - -export const mpinCrypto = { - async getPublicKey(): Promise { - 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; + return importKey(publicKey); }, /** @@ -70,6 +56,10 @@ export const mpinCrypto = { * - `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. + * + * The result is single-use and must be sent within 120 seconds: call this + * immediately before each request, and never reuse one ciphertext for a + * login and a transfer. */ async encryptMpin(mpin: string): Promise { const publicKey = await this.getPublicKey(); diff --git a/src/services/storage/appDataPurge.ts b/src/services/storage/appDataPurge.ts index 49f8d97..01fd59d 100644 --- a/src/services/storage/appDataPurge.ts +++ b/src/services/storage/appDataPurge.ts @@ -1,7 +1,6 @@ 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'; @@ -72,7 +71,6 @@ export const purgeUserData = async () => { await featureRemoteConfig.forgetRoleScope().catch(() => {}); sessionManager.clear(); - mpinCrypto.invalidate(); }; const clearAsyncStorage = async () => {