DEV Community

Cover image for True Randomness in Laravel with Lararand
Eduardo Lázaro
Eduardo Lázaro

Posted on

True Randomness in Laravel with Lararand

Here is a line of PHP that deals a card from a tarot deck. It is wrong.

$card = ord(random_bytes(1)) % 78;
Enter fullscreen mode Exit fullscreen mode

Not wrong in the way a linter finds. It returns a number between 0 and 77 every single time. It never throws. Every test you would think to write about it passes: the range is right, the values are distinct, the distribution looks fine if you eyeball a hundred draws.

It is wrong in the way that only shows up if you count.

The arithmetic

A byte holds 256 values. A deck holds 78. 256 is not a multiple of 78.

The largest multiple that fits is 234, which is 78 × 3. So bytes 0 to 233 cover the deck three times, evenly, no argument. And then there are 22 bytes left over: 234 to 255. Those wrap around and land on cards 0 to 21 a fourth time.

Count it up:

  • 22 cards appear 4 times per 256 bytes
  • 56 cards appear 3 times per 256 bytes

That is 22 × 4 + 56 × 3 = 256. The books balance, and 22 of your 78 cards come up 33% more often than the other 56. Forever. In production. Silently.

If you are dealing tarot, The Fool through The Devil are favoured over everything after them. If you are running a raffle, the first 22 ticket holders are. If you are picking a shard, one in three of them gets a third more traffic than the rest.

Why no test catches it

Because the tests you naturally write are about the shape of the answer, and the shape is perfect.

$this->assertGreaterThanOrEqual(0, $card);
$this->assertLessThan(78, $card);
Enter fullscreen mode Exit fullscreen mode

Passes. Always will.

The test that catches it has to be about the distribution, and it has to be exhaustive rather than statistical. A statistical test of a 33% skew on 22 of 78 outcomes needs a lot of samples before it fires, and it will be flaky when it does.

The exhaustive one is easy once you see it. Feed every byte value through and count:

$counts = array_fill(0, 78, 0);

foreach (range(0, 255) as $byte) {
    $counts[$byte % 78]++;
}

// [4, 4, 4, ... 3, 3, 3]
Enter fullscreen mode Exit fullscreen mode

Twenty-two fours and fifty-six threes, staring at you.

The fix, and why it belongs in one place

Rejection sampling. Throw away the bytes that land in the uneven tail and draw again:

$limit = intdiv(256, 78) * 78;   // 234

do {
    $byte = ord(random_bytes(1));
} while ($byte >= $limit);

$card = $byte % 78;
Enter fullscreen mode Exit fullscreen mode

Now every one of the 78 outcomes gets exactly three of the 234 surviving bytes. The cost is a redraw on 22 of 256 bytes, 8.6% of the time, and the answer is right.

The important part is not the four lines. It is that they should exist once in a codebase, not in every place that turns bytes into a number. The bug is not hard. The bug is that it is invisible, so every function that rediscovers it has an even chance of rediscovering it wrongly, and none of them will tell you.

The same mistake, wearing a different hat

Shuffling has an identical trap.

// Wrong
for ($i = 0; $i < $n; $i++) {
    $j = random_int(0, $n - 1);
    [$a[$i], $a[$j]] = [$a[$j], $a[$i]];
}
Enter fullscreen mode Exit fullscreen mode

That swaps each position with any position. It looks more random than the correct version, which is exactly why people write it. It produces nn execution paths spread over n! possible orderings, and for every n above 2 those do not divide. Some permutations come out more often than others.

The correct version swaps with a position at or below the current one, walking down:

for ($i = $n - 1; $i > 0; $i--) {
    $j = below($i + 1);          // 0..$i, unbiased
    [$a[$i], $a[$j]] = [$a[$j], $a[$i]];
}
Enter fullscreen mode Exit fullscreen mode

Same class of bug. Same silence. nn over n! is the modulo bias again with more steps.

Why I ended up writing a package

I was building a tarot site where the whole claim is that nobody arranged the outcome. That put two problems on the table at once.

The first is the one above: the arithmetic has to be right, and it has to be right in one place.

The second is stranger, and it is the actual reason the package exists. For most software, random_int is the correct and final answer. It is the system CSPRNG, it is what every session id on the box already rests on, it costs nothing, and it cannot be down. If you are choosing a shard or picking a placeholder avatar, stop reading, you are done.

But sometimes the origin of the randomness is part of what you are selling. A draw someone could contest. A shuffle a regulator asks about. And there:

"Our lottery runs on quantum vacuum noise measured at the Australian National University" is a sentence you can put in front of a customer.

"We called random_int" is not, even though it is a better answer technically.

The ANU publishes a free endpoint that measures vacuum fluctuations. random.org sells atmospheric noise. Both are real, both are lovely, and both introduce a problem you did not have five minutes ago: a draw that fails in front of a customer is worse than one drawn locally.

So the source became a config line:

'chain' => ['anu_public', 'anu_quantum', 'random_org', 'system'],
Enter fullscreen mode Exit fullscreen mode

Tried left to right, first one that answers serves the bytes. The last entry is the one with nothing underneath it: put system there and a draw can never fail, leave it out and a draw fails loudly when every provider does. Which of those you want is a real decision, and it should be visible in a config file rather than hidden behind a boolean called fallback that secretly means "the CSPRNG".

The package

It is lararand, MIT, PHP 8.2 and Laravel 12.

composer require edulazaro/lararand
Enter fullscreen mode Exit fullscreen mode
use EduLazaro\Lararand\Facades\Rand;

Rand::below(78);          // 0 to 77, no modulo bias
Rand::int(1, 6);          // both ends included
Rand::distinct(6, 49);    // six of 49, no repeats
Rand::shuffle($deck);     // an actual permutation
Rand::one($items);
Enter fullscreen mode Exit fullscreen mode

One contract, and it is bytes. Everything else is derived by the package, because the derivation is where the bug lives.

👉 Package on Packagist: https://packagist.org/packages/edulazaro/lararand
👉 Source on GitHub: https://github.com/edulazaro/lararand

Top comments (0)