import 'react-native-get-random-values'; 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 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)); return forge.pki.publicKeyFromAsn1(asn1) as forge.pki.rsa.PublicKey; }; 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) { throw new CbsApiError({ status: 0, code: 'UNKNOWN_ERROR', message: 'Could not fetch MPIN encryption key', }); } return importKey(publicKey); }, /** * 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. * * 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(); 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() }, }), ); }, };