n-digit-token

Cryptographically secure pseudo-random token of n digits

Usage no npm install needed!

<script type="module">
  import nDigitToken from 'https://cdn.skypack.dev/n-digit-token';
</script>

README

n-digit-token

Generate a cryptographically secure pseudo-random token of N digits.

Cryptographically strong random number generator Modulo bias avoided Test coverage Algorithmic time complexity Downloads

Quick start

gen(n) where n is the desired length/number of digits.

const { gen } = require('n-digit-token');

const token = gen(6);
// => '681485'

Summary

This tiny module generates an n-digit cryptographically strong pseudo-random token in constant time and avoids modulo bias.

Modulo bias

The 2.x version of the n-digit-token algorithm does avoid modulo bias therefore providing high precision even for larger tokens.

Performance

This algorithm runs in O(1) constant time for up to a 100 digit long token sizes making it suitable for cryptographic applications (and I don't know why you would ever need longer tokens).

Comparisons

Algorithm Cryptographically strong? Avoids modulo bias?
average RNG :x: :x:
crypto.randomInt :x: :heavy_check_mark:
n-digit-token :heavy_check_mark: :heavy_check_mark:

As of n-digit-token@2.x

Detailed usage

Just give the desired token length to get your random n-digit token.

const { gen, generateSecureToken } = require('n-digit-token');

const token = gen(6);
// => '681485'

const anotherAuthToken = generateSecureToken(6);
// => '090188'

const anEightDigitToken = gen(8);
// => '25280789'

gen() is just a shorthand for generateSecureToken() use whichever you prefer.

Typescript

Or in typescript:

import { gen } from 'n-digit-token';

const token: string = gen(6);
// => '681485'

Advanced options

There are also some advanced options though most users should not need these.

Details

Background

I was looking for a simple module that generates an n-digit token that could be used for 2FA among others and was surprised that I couldn't find one that uses a cryptographically secure number generator (CSPRNG)

If your application needs cryptographically strong pseudo random values, this uses crypto.randomBytes() which provides cryptographically strong pseudo-random data.

Algorithmic properties

Performance

The n-digit-token algorithm executes with O(1) time complexity, i.e. in constant time when length <= 100. This makes n-digit-token suitable for cryptographic use cases.

Normally, you would never need to generate tokens that are above a few digits, such as 6 or 8, so this threshold is already an overkill.

The expected execution time of generating a token where length <= 1000 is still within 1 ms on a modern CPU.

Entropy

Note that for a cryptographic PRNG the system's entropy is an important factor. The n-digit-token function will wait until there is sufficient entropy available as it is uses the crypto.randomBytes() method.

This should normally not take longer than a few milliseconds unless the system has just booted very recently.

You can read more about this here.

Libuv's threadpool

As n-digit-token is dependent on crypto.randomBytes() it uses libuv's threadpool, which can have performance implications for some applications. Please refer to the documentation here for more information.

Time complexity chart

To test the consistency of the speed of the algorithm on a modern CPU, n-digit-token was called to generate a token of length 1 to 1000 on an AMD EPYC 7000 clocked at 2.2 GHz. This test was repeated a 1000 times on different occasions and the times were averaged.

The below chart represents the time it takes (in nanoseconds) to generate a token of length x:

Time taken per token length

Time complexity

y-axis shows time in nanoseconds / token length (AMD EPYC 7000 @ 2.2 GHz)

From this test and the diagram above it is shown that for up to ~100 digits the running time is constant, for larger tokens, the time taken is growing by gradually more.

As this algorithm is not designed to be used as a pseudo random digit stream, but to generate fixed-size tokens, this matches expectations. That said, it would be technically feasible to generate a large number of short tokens via this module that still runs in constant time, and then concatenate the tokens to a large stream.

Memory usage

By default the algorithm ensures modulo precision whilst also balancing performance and memory usage.

In order to achieve O(1) running time for lengths 1-100 the algorithm will attempt to reserve memory linearly scaling with the desired token length.

For token sizes between 1-32 the maximum used memory will not exceed 128 bytes. For insanely large tokens, such as a 1000 digits, the max memory by default is still within 1 kibibyte.

Options

There are a few supported customisation options for the algorithm for some highly specific use cases.

:exclamation: Most users will NOT need to change any of these options. :exclamation:

optional default value
options.returnType :heavy_check_mark: 'string'
options.skipPadding :heavy_check_mark: false
options.customMemory :heavy_check_mark: undefined
options.customByteStream :heavy_check_mark: undefined

options.skipPadding

Padding is an important concept regarding this algorithm.

If you aim to change this option, please make sure to read both skipPadding & returnType carefully to avoid unintended consequences.

Generating digits & padding

Generate a single-digit decimal

Since this algorithm aims to generate decimal numbers from a cryptographically strong random byte stream, the distribution of the generated numbers will mostly follow a natural distribution.

This means that if you generate a single digit token, you are mostly equally likely to hit any of the decimal numbers 0-9 inclusive. Note that, you can therefore get zero as a result (as you should be able to do so).

For example, calling gen(1) can result in the decimal number 9 and the token '9' (since the default return type is string):

const token = gen(1);
// internally:
1) length=1 means max=9               (-> max=9)
2) roll a number between 0-9          (-> rolls 9)
3) convert it to string               (-> '9')
4) return
=> '9'
Generate a multi-digit decimal

On the other hand, for multi-digit tokens, you will be mostly equally likely to hit any of 0-99 meaning that you can still hit a single digit decimal number.

For example, calling gen(2) can internally result in the decimal number 9 again, since it is a valid random number on the range 0-99. However, since the user wanted to receive a 2-digit token, the returned token string will need to be padded by a 0. Therefore, you will get '09' as the token.

const token = gen(2);
// internally:
1) length=2 means max=99              (-> max=9)
2) roll a number between 0-99         (-> rolls 9)
3) convert it to string               (-> '9')
4) pad if less than desired length    (-> '09')
5) return
=> '09'

Equally random

Now you should see why it may be necessary to pad the generated numbers.

Why not just discard numbers that start with 0?

You might be wondering, why can't we just discard numbers that start with zeros rather than to pad them.

Whilst it would be a valid approach to say that we could just discard any numbers that are lower than the desired number of digits, it would defeat the purpose of using a cryptographically strong seed.

In order to provide the closest to a truly random distribution of generated numbers, it is essential that the minimum possible value is 0 as the CSPRNG functions provide a pseudo random stream of binary data.

How much discarded

Furthermore, just think about in how many cases you would need to re-roll for larger tokens. For example for gen(6) in order to have a 6-digit number any numbers below 100000 would have to be discarded. That's 10000 or 10 ** (length-1) cases (0-99999).

const token = gen(6);
=> '009542'    // 10% chance to discard

Besides, there are already many average random number generators where you can specify an integer range for both min and max that focuses less on precision.

One-time tokens often start with zeros

As you may have noticed if you use 2FA, many one time tokens do start with zeros. If they use a bit-stream it has a ~10% chance and this should also explain why n-digit-token can return a token starting with zero.

Using skipPadding

Setting options.skipPadding=true will skip padding any tokens that are shorter than the input length.

Therefore, n-digit-token may return varied token lengths!

:warning: Varied token lengths :warning:

Make sure your application is able to handle that the returned token may be of different lengths.

Example

If skipPadding=true then length will be the maximum returned token length.

const { gen, generateSecureToken } = require('n-digit-token');

const token = gen(6, { skipPadding: false }); // equivalent to gen(6)
=> '030771'

const token = gen(6, { skipPadding: true });
=> '30771'

options.returnType

By default the algorithm returns the generated token as a string.

This option allows you to customise the return type of the generated token.

You can choose from:

  • 'string'
  • 'number' (i.e. 'integer')
  • 'bigint'

:warning: Note that only string guarantees a fixed length output! :warning:

If you aim to change this option, please make sure to read both skipPadding & returnType carefully to avoid unintended consequences.

Return type compatibility

Please refer to the below table to see the compatibility of the return types:

return type / token length 1-15 16+
'string' :heavy_check_mark: :heavy_check_mark:
'number' (integer) :heavy_check_mark: :x:
'bigint' :heavy_check_mark: :heavy_check_mark:

Examples

const { gen, generateSecureToken } = require('n-digit-token');

const token = gen(6);
=> '440835'

const anotherStringToken = gen(16, { returnType: 'string' });
=> '8384458882874956'

const aNumberToken = gen(6, { returnType: 'number' });
=> 225806

const aBigIntToken = gen(16, { returnType: 'bigint' });
=> 9680644450112709n

Using returnType with skipPadding

Some return types will automatically skip padding.

For example, if the token is returned as a number there is no way to pad with zeros if shorter.

In other words, some return types require and automatically set skipPadding=true.

Compatibility table

return type / padding skipPadding padWithZeros
'string' optional default
'number' required impossible
'bigint' required impossible

Examples

const { gen, generateSecureToken } = require('n-digit-token');

// the below is equivalent to gen(6) i.e. default
const token = gen(6, { returnType: 'string', skipPadding: false });
=> '012345'

const token = gen(6, { returnType: 'string', skipPadding: true });
=> '12345'

// the below is equivalent to gen(6, { returnType: 'number' });
const token = gen(6, { returnType: 'number', skipPadding: true });
=> 12345

// the below is equivalent to gen(6, { returnType: 'bigint' });
const token = gen(6, { returnType: 'bigint', skipPadding: true });
=> 12345n

options.customMemory

This is a highly advanced option. Please read memory usage before proceeding.

If you need to limit the used memory, you can do so by specifying the amount of bytes you can allocate via the options.customMemory option.

For example, if you can only allocate 8 bytes, you could do the following:

const { gen, generateSecureToken } = require('n-digit-token');

const token = gen(6, { customMemory: 8 });

:warning: Performance implications :warning:

Please note that both giving too few or too much memory to the algorithm may negatively impact performance by a considerable amount.

If the application detects unsuitable amount of memory, it may warn you in the debug console, but will continue to execute.

options.customByteStream

This is an advanced option. You should only use this if you don't have access to node crypto.

With this option you can specify a custom synchronous CSPRNG byte stream function that returns a Buffer that n-digit-token will use.

You may find use of this option if you need to run n-digit-token in the browser with e.g. crypto-browserify:

const { randomBytes } = require('crypto-browserify');
const { gen, generateSecureToken } = require('n-digit-token');

const token = gen(6, { customByteStream: randomBytes });

Please note that this is option has only been tested with crypto-browserify and inappropriate use may lead to various unintended consequences.

options.avoidModuloBias (deprecated)

This setting has been deprecated as of n-digit-token@v2.x since the algorithm avoids modulo bias by default. Therefore, the use of this option is now unnecessary and ignored by the application.

Compatibility

n-digit-token supports node >= 10.4.0. There are no additional compatibility requirements.

Dependencies

:tada: 0 dependencies :tada:

This package is solely dependent on the built-in nodeJS/crypto module.

Running in browser

Please note that n-digit-token is intended to be used server-side and therefore browser support is not actively maintained.

However, as of v2.0.2 you can use n-digit-token with crypto-browserify or other custom byte streams.

Please refer to the customByteStream option for more details.

Test

Install the devDependencies and run npm test for the module tests.

Scripts

  • npm test to see interactive tests and coverage
  • npm run build to compile JavaScript
  • npm run lint to run linting

Support n-digit-token

Financial support

If you like this project, please consider supporting n-digit-token with a one-time or recurring donation as this project takes considerable amount of time and effort to develop and maintain.

Star this project

If you can't support n-digit-token financially, please consider giving the project a GitHub:star: to help its discoverability. Thank you!

Contributing

Code contributions are also warmly welcomed!

License

MIT © Daniel Almasi