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 => { try { const response = await apiClient.get(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 { 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 { 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() }, }), ); }, };