Solution: bip39
and node-forge
To quote this answer which has guided me to achieve this solution:
in this scenario, the resulting public key is, by nature, public, and
thus can serve for offline dictionary attacks. The attacker just has
to try possible passwords until he finds the same public key. That's
intrinsic to what you want to achieve.
We can assume that 128 bits of entropy should be enough for preventing this kind of attacks, in the foreseeable future, however we can fortunately decide how strong our mnemonic will be.
1. Generate mnemonic
First of all we can generate a mnemonic using the bip-39
JS implementation.
import { generateMnemonic } from "bip39";
const mnemonic = generateMnemonic(256) // 256 to be on the _really safe_ side. Default is 128 bit.
console.log(mnemonic) // prints 24 words
2. Create deterministic PRNG function
Now we can use node-forge
to generate our keys.
The pki.rsa.generateKeyPair
function accepts a pseudo-random number generator function in input. The goal is getting this function to NOT compute a pseudo-random number (this would not be deterministic anymore), but rather return a value computed from the mnemonic.
import { mnemonicToSeed } from "bip39";
import { pki, random } from "node-forge";
const seed = (await mnemonicToSeed(mnemonic)).toString('hex')
const prng = random.createInstance();
prng.seedFileSync = () => seed
3. Generating keypair
We can now feed the generateKeyPair
function with our "rigged" prng:
const { privateKey, publicKey } = pki.rsa.generateKeyPair({ bits: 4096, prng, workers: 2 })
Et voilà!
We now have safe and deterministic RSA keys, directly generated on the client and restorable with the same mnemonic as a input.
Please consider the risks involved using deterministic keys and make sure your users will NOT store the mnemonic online or anywhere else on their client (generally, it is suggested to write it down on paper and store it somewhere safe).