81 lines
3.0 KiB
TypeScript
81 lines
3.0 KiB
TypeScript
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<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 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<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() },
|
|
}),
|
|
);
|
|
},
|
|
};
|