Skip to content

fix: generate coupon codes with a CSPRNG instead of Math.random - #12

Open
BrianWillows wants to merge 1 commit into
chilts:masterfrom
BrianWillows:fix/csprng-codes
Open

fix: generate coupon codes with a CSPRNG instead of Math.random#12
BrianWillows wants to merge 1 commit into
chilts:masterfrom
BrianWillows:fix/csprng-codes

Conversation

@BrianWillows

Copy link
Copy Markdown

Summary

randomSymbol() picks each code character with Math.random():

function randomSymbol() {
    return symbolsArr[parseInt(Math.random() * symbolsArr.length, 10)];
}

Math.random() is not cryptographically secure — V8 implements it with
xorshift128+, whose internal state can be recovered from a handful of observed
outputs. Because the fourth character of each part is a deterministic checksum,
the entire coupon is a pure function of Math.random() output, so someone who
receives a few issued coupons can predict or enumerate other valid codes
(CWE-338, use of a cryptographically weak PRNG).

Coupon codes gate redeemable value, so guessability matters here.

Fix

Draw the symbols from crypto.randomBytes() with rejection sampling, which
keeps the distribution uniform — a plain % 32 over a 32-bit value would bias
the symbol set.

crypto is a core module, so no new dependency is introduced.

Verification

  • Existing test suite passes: 31/31 tests pass (npm test), including all
    the checksum-validation cases.
  • Behaviour unchanged:
    • format still XXXX-XXXX-XXXX (Y03B-EH76-W2XM)
    • generated codes still pass validate() round-trip
    • {parts: 4} still works (021G-G7HM-X3VU-8L0E, validates)
    • 3000 generated codes → 3000 unique
    • all 32 symbols still used

Notes

Found and fixed with AI assistance (Claude). If you consider coupon codes
non-security by design (the Algorithm::CouponCode heritage is about typo
resistance rather than unpredictability), feel free to close — but since the
codes are redeemable, a CSPRNG seemed the safer default. Happy to add a
regression test.

randomSymbol() picked each character with Math.random(), which is not
cryptographically secure - V8's xorshift128+ state can be recovered from a
handful of observed outputs, so someone who receives a few issued coupons
can predict or enumerate other valid codes (CWE-338). Since the fourth
character of each part is a deterministic checksum, the whole code is a
pure function of Math.random() output. Coupon codes are redeemable value,
so they should not be guessable.

Use crypto.randomBytes() with rejection sampling, which keeps the symbol
distribution uniform (a plain modulo would bias it, as 2^32 is not a
multiple of 32).

Output format, checksums and the parts option are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants