Back to Knowledge Hub
Fingerprint

Crypto-JS Random Source: Which Branch Your Build Uses

Read crypto-js 4.2.0 and 3.1.2 source, then measure which entropy call each version makes, how many calls a request costs, and what a stubbed crypto object can change.

BotBrowser Team

Documentation

Want the structured docs for Fingerprint?

This article lives in the editorial library. For step-by-step setup, reference material, and ongoing updates, jump into the docs section.

BotBrowser supports replaying an authorized, same-origin fixture under a declared profile and keeping bounded observations about which API a page called and how often. It does not inspect the browser entropy pool, certify randomness quality, or turn a library call count into an identity signal. This article reads two published crypto-js builds, then measures the observable parts: which entropy call each version binds, what one request costs, and what a test stub can and cannot replace.

TL;DR

  • [email protected] reaches crypto.getRandomValues for every word it fills; [email protected] reaches Math.random instead. The API surface looks identical, so only the resolved source tells them apart.
  • One WordArray.random(n) request makes about n / 4 entropy calls, so a 64-byte request costs sixteen round trips into the crypto object rather than one.
  • The binding loop in core.js assigns three globals without an existence guard. globalThis.crypto overwrites a window.crypto stub, which is why a jsdom test that stubs only window.crypto observes zero calls into its stub.
  • After the module loads, replacing the method on the held crypto object takes effect; replacing the whole globalThis.crypto object does not.
  • A stub that throws makes 4.2.0 raise its own generic error. It does not fall back to Math.random.

Contents

A language-neutral diagram: three crypto bindings resolve to one held object, one request becomes ceil of n over 4 calls, and only a method swap applies after load.

Which entropy source each build binds

The published core.js of [email protected] starts with a var crypto; declaration and then runs six assignments in a fixed order. The order matters more than the comments beside it, because only the last three assignments test whether a source was already found.

// [email protected] core.js
if (typeof window !== 'undefined' && window.crypto) crypto = window.crypto;
if (typeof self !== 'undefined' && self.crypto) crypto = self.crypto;
if (typeof globalThis !== 'undefined' && globalThis.crypto) crypto = globalThis.crypto;
if (!crypto && typeof window !== 'undefined' && window.msCrypto) crypto = window.msCrypto;
if (!crypto && typeof global !== 'undefined' && global.crypto) crypto = global.crypto;
if (!crypto && typeof require === 'function') crypto = require('crypto');

The first three lines have no guard, so each one can overwrite the result of the line above it. In a browser, self and window are the same object, so the second line is a no-op rewrite of the first. In a Node process that also defines a window object, both the first and the second are overwritten by globalThis.crypto on the third line. The guarded lines then only matter when nothing was found at all.

That single detail explains a test result that otherwise looks impossible. A jsdom suite often defines globalThis.window = { crypto: stub } to control randomness, then requires the library and watches for calls into the stub. In a modern Node process the suite sees none, because globalThis.crypto exists and the third unguarded line replaces the stub before the module finishes loading.

The measurement confirms it. Two child processes start identically except for one line, and each counts stub calls and native calls over one WordArray.random(16) request:

window stub with globalThis.crypto present -> {"stubCalls":0,"allZero":false}
window stub with globalThis.crypto absent  -> {"stubCalls":4,"allZero":true}

In the first case the stub received nothing and the real crypto object received four calls. In the second case, with globalThis.crypto set to undefined before the require, the stub received all four and produced an all-zero word array, which is what a stub that fills zero bytes should produce.

The two library versions sit on different sides of the same API. Both expose CryptoJS.lib.WordArray.random(nBytes), and the resulting object has the same shape in both, so a test that only checks sigBytes passes on either. Instrumenting the two sources in one process separates them:

one call each in one process: getRandomValues=4 (v4.2.0) Math.random=4 (v3.1.2)
v3.1.2 random(16): Math.random=4

Four calls to getRandomValues for the modern build and four calls to Math.random for 3.1.2, for the same 16-byte request. The legacy helper reads Math.random() and scales it into a 32-bit integer; the modern helper asks the crypto object for new Uint32Array(1) and takes element zero. Neither path is slower in a way a page can see, and neither changes the returned type, so the version number in package.json is the only cheap way to know which one a bundle contains.

What one random request costs

The word helper fills one 32-bit word per pass, so the loop count follows the requested byte count directly. Measuring the crypto object instead of the clock makes the rule visible:

v4.2.0 random(4) -> getRandomValues calls=1
v4.2.0 random(16) -> getRandomValues calls=4
v4.2.0 random(64) -> getRandomValues calls=16

Sixteen calls for a 64-byte request, not one. The cost is Math.ceil(n / 4) for a positive integer, and the browser's own quota is applied per call rather than per request, so the practical limit is 16,384 calls of four bytes rather than 65,536 bytes in one call. A workload that generates a large amount of random material in a loop therefore pays many small quota checks instead of one large one.

That rule has a direct consequence for a fixture that wants to stay inside a browser quota. Four thousand and ninety-six bytes costs one thousand and twenty-four trips into the crypto object, and splitting that budget into eight calls of five hundred and twelve bytes costs the same total, because each of those calls is itself split into one hundred and twenty-eight four-byte words. Batching at the API boundary does not reduce the number of entropy calls; only the total byte count does. A loop that generates a large amount of key material in a test will therefore reach the quota at the same total size whether it asks for one array or many.

The other side of the same rule is timing. On the measured host a thousand thirty-two-byte requests completed in a few milliseconds, which is small enough that the cost of the loop is invisible beside the cost of whatever the material is used for. That is why the call count, not the duration, is the useful thing to record: the duration moves with the machine and the load, and the count does not.

Fractional and invalid sizes show the loop's boundary behaviour, and none of them raise:

random(0) -> words=0 sigBytes=0 hexLen=0
random(4) -> words=1 sigBytes=4 hexLen=8
random(4.5) -> words=2 sigBytes=4.5 hexLen=10
random(16) -> words=4 sigBytes=16 hexLen=32
random(-1) -> words=0 sigBytes=-1 hexLen=0

A negative request returns an empty word array whose sigBytes is -1, and the hex encoder then emits nothing rather than throwing. A caller that trusts sigBytes as a length check gets a value that is falsy but not zero, which is the kind of value that passes an if (words.sigBytes) test and then fails later in a serializer.

For a well-formed request the output length is stable. Five thousand sixteen-byte requests in one process produced thirty-two hex characters every time:

5000 calls of random(16): hex length min=32 max=32

That stability is worth stating because it separates two things a test could confuse. A fixed output length proves the encoder is consistent; it says nothing about the entropy behind the characters. Those two claims need different evidence, and only the first is cheap to check.

Where a test stub loses to a later global

Stubbing the random source is normal practice for a deterministic test suite, and the failure mode is subtle: the stub is installed, the assertions pass, and the stub was never called. Two placements behave differently after the module has already loaded.

post-load swaps -> {"methodSwapAllZero":true,"objectSwapAllZero":false}

Replacing getRandomValues on the object the library already holds changes the next result, and the word array comes back all zeros. Replacing globalThis.crypto with a fresh object afterwards changes nothing, because the library kept a reference to the object it resolved during load and never looks the global up again. The first form is the one a test can use; the second form silently leaves the real source in place.

Order matters for the same reason. A suite that sets globalThis.window.crypto and then requires the library is fighting the third unguarded assignment. Setting globalThis.crypto before the require, or clearing it, is the placement that reaches the library's binding loop at the moment it runs.

What happens when the crypto object exists but refuses is a third distinct behaviour. A stub whose getRandomValues always throws produced this:

getRandomValues always throws -> threw: Native crypto module could not be used to get secure random number.

The library swallows the original error and raises its own plain message. Whatever a quota failure was originally named is gone by the time the caller sees it, so a page that wants to distinguish a quota problem from a missing source has to inspect the crypto object itself rather than the error the library throws. Equally important, 4.2.0 does not fall back to Math.random in that case. The silent-degradation risk belongs to 3.1.2, where Math.random is the source rather than the fallback.

A caller can still tell the two failures apart without reading the library. Test whether the crypto object exposes getRandomValues before the request; if it does, a thrown error came from the source rather than from a missing binding. That check costs one property read and turns an ambiguous error into a decision, which is what a fixture needs in order to record source-missing separately from source-refused.

Where the hex text comes from

The word array stores 32-bit words, and the hex encoder reads those words from the most significant byte down. Nothing in that step depends on the library version; what varies is how a four-byte group becomes a word in the first place. Feeding the same sixteen entropy bytes through two interpretations makes the effect visible: The same grouping boundary appears when text is encoded for transport: btoa and atob boundaries shows how a byte string and a character string diverge and why a decode step is mandatory before the text matches again.

entropy bytes: 00112233445566778899aabbccddeeff
WordArray(little-endian words, as the randomBytes branch) -> 3322110077665544bbaa9988ffeeddcc
WordArray(big-endian words)                               -> 00112233445566778899aabbccddeeff
endianness changes the hex: true

Both runs consumed identical bytes and produced different text. The difference is the grouping rule, not the entropy: one reading treats each four-byte group as little-endian, the other as big-endian, and the hex encoder faithfully prints whichever 32-bit value it was handed. A comparison across two hosts that group differently therefore reports a difference that has nothing to do with randomness.

The signed form does not add a second difference. The two words -1146447480 and -1122868 and their unsigned equivalents 3148519816 and 4293844428 encode to the same sixteen hexadecimal characters, because the encoder masks each byte:

signed words [-1146447480,-1122868] -> bbaa9988ffeeddcc
unsigned words [3148519816,4293844428] -> bbaa9988ffeeddcc
sign is lost in the hex: true

This is why the Node branch, which reads four bytes with readInt32LE() and can therefore produce negative words, still agrees with the browser branch on a little-endian host. The measured host reported little-endian, and on that host the two branches form the same words from the same bytes. A big-endian host would not, because the browser branch takes the machine interpretation of a Uint32Array element while the Node branch pins little-endian explicitly. That last sentence is a reading of the two code paths, not a measurement: only the little-endian case was run here, and the big-endian case stays an open check for anyone with that hardware.

The practical lesson for a comparison across machines is narrower than it first appears. If two hosts run the same build and both are little-endian, the branch does not matter and the hex agrees. If the versions differ, the source changes and the hex agrees only by coincidence. A diff between two artifacts therefore tells you which of the two variables moved only when the byte order is recorded beside it, which is one more field the record needs.

What a passphrase encrypt pulls from the source

AES.encrypt has two entry shapes that look similar and use the random source differently. Passing a string as the key means the library derives a key and an IV, and that path generates a salt first. Passing a parsed key with an explicit IV skips the derivation entirely.

AES.encrypt(passphrase) -> getRandomValues calls=2
AES.encrypt(key+iv) -> getRandomValues calls=0
passphrase output header="Salted__" saltBytes=8
explicit key+iv deterministic across runs: true

Eight salt bytes cost two calls, which matches the one-word-per-pass rule. The output string carries the eight-byte salt in front of the ciphertext behind a fixed header, so two runs with the same passphrase and the same plaintext differ only in those eight bytes and everything derived from them. The explicit-key form made zero calls and produced identical output across two runs, which is the property a deterministic test needs and the property a stored artifact must not have.

The two key-derivation defaults also sit far apart in cost. The passphrase path uses an OpenSSL-compatible derivation with one iteration by default, while the standalone PBKDF2 helper defaults to 250,000:

EvpKDF keySize=12 iterations=1: 0.06 milliseconds
PBKDF2 default iterations=250000: 851 milliseconds

Both figures come from one machine and move with load, so treat them as an order of magnitude rather than a benchmark. The point they support is stable: choosing the passphrase helper for a stored artifact buys a salt and a fast derivation, not a slow one, and raising the iteration count is a separate decision a caller has to make explicitly.

Record a library entropy reading

A reading that survives a review names its inputs and its limits. Six fields cover the observable part without storing any generated material:

  1. The package name and exact version, plus the file that supplies the word helper, because the binding order is a property of that file.
  2. The call counts per source, getRandomValues and Math.random, taken over one request of a stated size.
  3. The request size and the resulting sigBytes, so the call count can be checked against the one-word-per-pass rule.
  4. The host's byte order, because it decides whether the two source branches agree on the text.
  5. Whether a stub was installed before or after the module loaded, and which object it replaced.
  6. The error text produced when the crypto object refuses, including whether the original error name survived.

The record above is what a test suite can assert against, and it stays small enough to keep in a review note. What it cannot support is any statement about randomness quality, uniqueness, or what a page would report about the machine. A call count is a shape, not a measurement of entropy, and a stable hex length is an encoder property rather than evidence about the source behind it. For the browser-side contract that these calls land on, see crypto randomness observability and browser fingerprints, and for a pinned collector that separates per-component results from its own digest, see FingerprintJS OSS client collection and provenance.

Two habits keep a library reading honest over time. First, re-run the call count after any dependency bump, because a version change can swap the source without changing the API; this is exactly the difference between 3.1.2 and 4.2.0. Second, record the check that would fail if the binding order changed, which here is the stub-call count with and without a native global. A record that only stores the produced length cannot detect either change.

Public sources

Practical conclusion

Read the tagged core.js of the build you actually ship, then count the calls each source receives over one request of a known size. Name the version, the call counts, the byte order and the stub placement in the record. Treat the produced text as an encoder result, keep the entropy out of the record, and re-run the count whenever the dependency moves.

#Crypto-Js#getRandomValues#Math.random#Library Entropy#Stub Behavior

This article is part of the guide: What Is a Browser Fingerprint?

Take BotBrowser from research to production

The guides cover the model first, then move into cross-platform validation, isolated contexts, and scale-ready browser deployment.