Fetch the MPIN encryption key fresh before every encryption
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ddac198ea7
commit
ee6a408a1a
@@ -15,15 +15,15 @@ import type {
|
|||||||
/**
|
/**
|
||||||
* `MALFORMED_REQUEST` on an MPIN call is what a stale or wrong public key looks
|
* `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
|
* 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
|
* would leak whether the profile exists. The key is fetched fresh on every
|
||||||
* try exactly once more. A second failure is a real problem worth surfacing.
|
* 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 <T>(call: () => Promise<T>): Promise<T> => {
|
const withKeyRetry = async <T>(call: () => Promise<T>): Promise<T> => {
|
||||||
try {
|
try {
|
||||||
return await call();
|
return await call();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isCbsApiError(error) && error.code === 'MALFORMED_REQUEST') {
|
if (isCbsApiError(error) && error.code === 'MALFORMED_REQUEST') {
|
||||||
mpinCrypto.invalidate();
|
|
||||||
return call();
|
return call();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,20 +3,11 @@ import forge from 'node-forge';
|
|||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
import { apiClient } from '../api/apiClient';
|
import { apiClient } from '../api/apiClient';
|
||||||
|
import { CbsApiError } from '../api/cbsErrors';
|
||||||
import { endpoints } from '../api/endpoints';
|
import { endpoints } from '../api/endpoints';
|
||||||
import type { PublicKeyResult } from '../../types/cbs';
|
import type { PublicKeyResult } from '../../types/cbs';
|
||||||
|
|
||||||
/**
|
/** The key arrives as Base64 X.509 SubjectPublicKeyInfo (SPKI) DER. */
|
||||||
* 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 importKey = (base64Der: string): forge.pki.rsa.PublicKey => {
|
||||||
const der = forge.util.decode64(base64Der);
|
const der = forge.util.decode64(base64Der);
|
||||||
const asn1 = forge.asn1.fromDer(forge.util.createBuffer(der));
|
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;
|
return forge.pki.publicKeyFromAsn1(asn1) as forge.pki.rsa.PublicKey;
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchKey = async (): Promise<forge.pki.rsa.PublicKey> => {
|
export const mpinCrypto = {
|
||||||
try {
|
/**
|
||||||
|
* 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<forge.pki.rsa.PublicKey> {
|
||||||
|
// A network failure here propagates as the usual NETWORK_ERROR /
|
||||||
|
// REQUEST_TIMEOUT from the interceptor.
|
||||||
const response = await apiClient.get<PublicKeyResult>(endpoints.crypto.publicKey);
|
const response = await apiClient.get<PublicKeyResult>(endpoints.crypto.publicKey);
|
||||||
const publicKey = response.data?.publicKey;
|
const publicKey = response.data?.publicKey;
|
||||||
|
|
||||||
if (publicKey) {
|
if (!publicKey) {
|
||||||
return importKey(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);
|
return importKey(publicKey);
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,6 +56,10 @@ export const mpinCrypto = {
|
|||||||
* - `ts` is in *seconds*. Milliseconds read as ~55,000 years in the future
|
* - `ts` is in *seconds*. Milliseconds read as ~55,000 years in the future
|
||||||
* and the request is rejected.
|
* and the request is rejected.
|
||||||
* - `nonce` must be fresh on every single call, or it is refused as a replay.
|
* - `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<string> {
|
async encryptMpin(mpin: string): Promise<string> {
|
||||||
const publicKey = await this.getPublicKey();
|
const publicKey = await this.getPublicKey();
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
import { featureRemoteConfig } from '../../features/featureRemoteConfig';
|
import { featureRemoteConfig } from '../../features/featureRemoteConfig';
|
||||||
import { mpinCrypto } from '../security/mpinCrypto';
|
|
||||||
import { sessionManager } from '../security/sessionManager';
|
import { sessionManager } from '../security/sessionManager';
|
||||||
import { secureStorage } from './secureStorage';
|
import { secureStorage } from './secureStorage';
|
||||||
|
|
||||||
@@ -72,7 +71,6 @@ export const purgeUserData = async () => {
|
|||||||
await featureRemoteConfig.forgetRoleScope().catch(() => {});
|
await featureRemoteConfig.forgetRoleScope().catch(() => {});
|
||||||
|
|
||||||
sessionManager.clear();
|
sessionManager.clear();
|
||||||
mpinCrypto.invalidate();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearAsyncStorage = async () => {
|
const clearAsyncStorage = async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user