28 Jan 2018 • Building a userspace CSPRNG on top of Monocypher 2

Addendum July 2021

This was written against Monocypher 2 and doesn't work with Monocypher 3. Also the stirring logic is bad, when you encrypt something the chacha state is unaffected by the data itself, i.e. the entropy doesn't actually get mixed in. You need to reinitialise the chacha context! Go here for the Monocypher 3 version.

Original post

Monocypher is an excellent crypto library that comes with everything you need, except a cryptographically secure RNG. The Monocypher manual says use the OS's CSPRNG, but those are not ideal. So this post covers how to build an ideal userspace CSPRNG on top of Monocypher.

On OpenBSD you have the getentropy syscall, which cannot fail, and is the gold standard for OS RNGs. On new Linux you have getrandom, which might or might not fail (I can't tell from the manual). On old Linux and OSX you have urandom, which can fail in 100 different ways. On other platforms you have various other mechanisms that might or might not fail.

Making your RNG signal failure is not a solution because people are not going to check for and handle that case correctly. So we need an RNG which never fails, which we can do by moving the RNG to userspace. We need to seed it with entropy from the kernel, but we can do that once at startup when killing the program isn't such a big deal.

Also syscalls are slow.

Monocypher implements Chacha20 for encryption. It's a stream cipher, so it works by taking the output of a CSPRNG and XORing it with the plaintext, so we can use its CSPRNG as our CSPRNG. Monocypher also conveniently exposes the RNG directly, but with other libraries you can encrypt all zeroes and use that.

Seeding with getentropy

Getting this right on every platform isn't very hard, especially when you don't care about recovering from failure, but if you don't want to implement it you can just copy the code from OpenBSD.

Otherwise, see:

Stirring/reseeding

OpenBSD's arc4random reseeds the Chacha20 context every 1.6MB for paranoia, and there may be other reasons why you would want to reseed the RNG (see below).

To reseed the RNG all you need to do is encrypt the seed. (add: no)

Threads

If you need random numbers in multiple threads there are a few approaches you can take:

fork

Fork works (roughly) by making a complete copy of the program's memory. That includes the RNG states, so both sides will output the same data from their RNGs, which is probably not desirable.

Using getpid doesn't work, because if

then Program A calls getpid. It will see that it's still Program A and assume it hasn't forked.

What we really want is some way to register a callback that gets called when the program forks, which is exactly what pthread_atfork is for.

Obviously if your program never forks, or only does fork+exec, you don't need to worry about this.

The code

I've not included any of the getentropy/atfork/threading code, but this should get you started:

crypto_chacha_ctx ctx;

void csprng_init() {
	uint8_t entropy[ 32 + 8 ];
	bool ok = getentropy( entropy, sizeof( entropy ) );
	if( !ok )
		abort();

	crypto_chacha20_init( &ctx, entropy, entropy + 32 );
	crypto_wipe( entropy, sizeof( entropy ) );
}

void csprng_random( void * buf, size_t n ) {
	crypto_chacha20_stream( &ctx, ( uint8_t * ) buf, n );
}

bool csprng_stir() {
	// if we're periodically stirring and this fails we can probably let it slide.
	// if we're in an atfork callback and this fails we have to abort.
	uint8_t entropy[ 32 ];
	bool ok = getentropy( entropy, sizeof( entropy ) );
	if( !ok ) {
		crypto_wipe( entropy, sizeof( entropy ) );
		return false;
	}

	uint8_t ciphertext[ 32 ];
	crypto_chacha20_encrypt( &ctx, ciphertext, entropy, sizeof( entropy ) ); // add: bad!
	crypto_wipe( entropy, sizeof( entropy ) );

	return true;
}