Update CBS Mobile App codebase
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
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<forge.pki.rsa.PublicKey> => {
|
||||
try {
|
||||
const response = await apiClient.get<PublicKeyResult>(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<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;
|
||||
},
|
||||
|
||||
/**
|
||||
* 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<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() },
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user