Summary
S.igamc(a, x) in src/functions/bip39_mnemonic.js calls a bare igam(a, x) that does not exist in module scope (only S.igam is defined). Since commit 009eb28, every call with x < 1.0 || x < a throws a ReferenceError instead of computing the complemented incomplete gamma function.
generateEntropy() wraps the NIST SP 800-22 randomness test in a blanket try/catch and treats any exception — including this ReferenceError — as "randomness test failed, regenerate". As a result, the Test for the Longest Run of Ones in a Block is silently inverted: buffers whose chi² statistic is too good (chi² < 3.0 — the statistically best ~61% of all buffers) crash the test and are rejected, while only buffers with moderately long runs of ones (chi² ∈ [3.0054, 11.3424], p-value ∈ [0.01, 0.391] in both 128-bit halves) are accepted.
The generator never crashes and produces normal-looking output, so the defect is invisible to users and downstream tests.
Root cause
Commit 009eb28 (2020-02-23) refactored the module to attach every function to the exported S object. One call site was not updated:
// src/functions/bip39_mnemonic.js (HEAD e070824, line 62)
S.igamc = (a, x) => {
if (x <= 0 || a <= 0) return 1.0;
if (x < 1.0 || x < a) return 1.0 - igam(a, x); // bare `igam` -> ReferenceError
...
In the test (S.randomnessTest, line 174):
if (!(S.igamc(k / 2, x_sqrt / 2) > 0.01))
throw new Error('Test for the Longest Run of Ones in a Block failed.');
with k = 3, so the buggy branch is taken whenever x_sqrt < 3.0. The exception is swallowed one level up:
// generateEntropy, line 194
try { S.randomnessTest(b); } catch (e) { f = false; }
The catch was meant for the test's explicit failure throws, but it also catches the ReferenceError, conflating "test says the buffer is bad" with "test crashed". Rejection sampling then silently keeps only the survivors.
Affected versions and products
| Product |
Status |
Evidence |
jsbtc 009eb28 (2020-02-23) → HEAD e070824, package v1.0.17 |
affected |
src/functions/bip39_mnemonic.js:62 |
dist/jsbtc.web.min.js (incl. build 08803aa, 2020-05-19) |
affected |
minified return 1-igam(i,e), no igam in bundle |
mnemonic-offline-tool, all builds embedding affected jsbtc — incl. 5b6dd99 (2020-06-19), the build used for the 1 BTC challenge |
affected |
embedded bundle byte-identical to dist/jsbtc.web.min.js@08803aa |
| jsbtc served by bitaps.com (Wayback 2020-08-22, 2020-11-24) |
affected |
same return 1-igam(i,e) pattern |
jsbtc ≤ c0c9dcb (pre-2020-02-23) |
not affected |
module-level igam/igamc, call resolves |
| pybtc (Python port) |
not affected by this specific defect |
module-level def igam resolves under Python scoping |
Impact (measured on the real, unmodified module code)
- Pass-rate of
randomnessTest: 0.141 (N=100 000) vs intended design 0.963 (one-token fix). Analytic estimate for the LR stage alone: P(3 ≤ chi²₃ ≤ 11.34)² ≈ 0.146.
- 82.7% of all CSPRNG buffers hit the
ReferenceError path; rejection sampling masks it completely.
- Conditional min-entropy loss ≈ 2.83 bits per accepted 256-bit buffer (−log₂(0.141)); intended design cost: 0.055 bits. The filter is a whole-buffer statistic; it gives no per-byte shortcut.
- ~6.8× CSPRNG over-consumption:
generateEntropy() averages 6.9–7.3 draws per call (fixed: 1.04).
- Silent inversion of a security check: the test now prefers buffers with unusually long runs of ones and rejects statistically excellent ones — the opposite of its purpose — with no error surfaced anywhere.
- Public API
randomnessTest(b) itself throws ReferenceError: igam is not defined on statistically excellent input — a plain correctness bug even when called directly.
Exact acceptance window (dynamic-programming enumeration of all 969 block-count compositions, 409 pass): chi² per 128-bit half ∈ [3.0054, 11.3424], i.e. p-value ∈ [0.01, 0.3908]. An independent numpy replica of the buggy filter reproduces the real JS code on 50 000 labeled buffers with 0 mismatches.
Impact on the "1 BTC challenge" specifically
The challenge secret is one generateEntropy({strength:128}) output; coefficient/index buffers pass the same filter. Conditioning on the two published shares (x=3, x=15) and the GF(2⁸) consistency constraints, the bug reduces an attacker's search from 2¹²⁷·⁷ to ≈ 2¹²⁶ oracle calls (refined estimate H ≈ 123.9 bits). To be explicit: the challenge is not broken by this bug — the loss is real but small. The primary severity is the silent inversion of a security control in a crypto library, in every affected build, since 2020-02-23 and still at HEAD.
Proof of concept
poc_igamc.js loads the unmodified src/functions/bip39_mnemonic.js (+ src/constants.js) from a local jsbtc checkout and runs four demonstrations:
node poc_igamc.js [path-to-jsbtc] [N] # defaults: /tmp/jsbtc-hist, N=100000
Actual output (HEAD e070824):
=== Part A: unit proof (real code) ===
[A] S.igamc(1.5, 1.0) on real code -> ReferenceError: igam is not defined
[A] fixed S.igamc(1.5, 1.0) = 0.572406704471 (reference 0.572406709661, |d|=5.19e-9)
=== Part B: pass-rate over 100000 CSPRNG buffers (real code) ===
[B] buggy code : pass=14073/100000 = 0.1407 (ReferenceError on 82681 buffers = 82.7%)
[B] fixed code : pass=96291/100000 = 0.9629 (intended design: ~0.96)
[B] CSPRNG consumption per accepted buffer: buggy 7.11x vs fixed 1.039x -> amplification 6.84x
[B] min-entropy loss bound per 256-bit buffer: buggy 2.83 bits vs fixed 0.055 bits
=== Part C: exhibits ===
[C] rejected via ReferenceError (statistically EXCELLENT buffer, would pass the intended test):
4a2a7680f9df44180d55587a1664bd43d857cc6e78cb7d09477802d955264101
chi2 halves = [3.006, 0.970], p-values = [0.391, 0.809]
[C] accepted by buggy code (moderately long runs):
764db94a97309e64ce1dd0ec16dc6cd88066dd7f25d35ea0e2a4f6e50700e710
chi2 halves = [6.251, 4.934], p-values = [0.100, 0.177]
=== Part D: real S.generateEntropy (silent, amplified CSPRNG use) ===
[D] buggy: 2000/2000 generateEntropy calls succeeded silently, 13751 CSPRNG draws (6.88 per call)
[D] fixed: 2000/2000 calls, 2084 draws (1.04 per call)
RESULT: VULNERABILITY CONFIRMED
Reproduced identically on two independent checkouts (full-history clone at HEAD e070824 and archived snapshot jsbtc-e070824eb877).
Full PoC source (poc_igamc.js)
// poc_igamc.js — PoC for the jsbtc `S.igamc` -> bare `igam` ReferenceError bug.
// Runs the REAL, unmodified module code from a local jsbtc checkout:
// node poc_igamc.js [path-to-jsbtc] [N]
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const candidates = [process.argv[2], '/tmp/jsbtc-hist'].filter(Boolean);
let ROOT = null;
for (const c of candidates) {
if (fs.existsSync(path.join(c, 'src/functions/bip39_mnemonic.js'))) { ROOT = c; break; }
}
if (!ROOT) { console.error('jsbtc checkout not found; pass path as argv[2]'); process.exit(2); }
const N = parseInt(process.argv[3] || '100000', 10);
const SRC_PATH = path.join(ROOT, 'src/functions/bip39_mnemonic.js');
const src = fs.readFileSync(SRC_PATH, 'utf8');
console.log(`[i] jsbtc source: ${SRC_PATH}`);
function makeS() {
const S = {};
function BN(b, radix) {
if (!(this instanceof BN)) return new BN(b, radix);
if (typeof b === 'string' && radix === 16) this._v = BigInt('0x' + b);
else this._v = BigInt('0x' + Buffer.from(b).toString('hex'));
}
BN.prototype.toString = function (r) { return this._v.toString(r || 16); };
BN.prototype.gte = function (o) { return this._v >= o._v; };
S.BN = BN;
S.__nodeCrypto = crypto;
S.getWindow = () => ({});
S.defArgs = (A, d) => { for (const k in d) if (A[k] === undefined) A[k] = d[k]; };
S.Buffer = class extends Buffer { hex() { return this.toString('hex'); } };
return S;
}
function loadModule(source, constantsSrc) {
const S = makeS();
for (const src of [constantsSrc, source]) {
if (!src) continue;
const m = { exports: null };
new Function('module', src)(m);
m.exports(S);
}
return S;
}
const BUGGY = '1.0 - igam(a, x)';
const FIXED = '1.0 - S.igam(a, x)';
const occ = src.split(BUGGY).length - 1;
console.log(`[i] buggy call site "${BUGGY}" occurrences in source: ${occ}`);
if (occ !== 1) { console.error('[!] unexpected source layout, aborting'); process.exit(2); }
const srcFixed = src.replace(BUGGY, FIXED);
const constantsSrc = fs.existsSync(path.join(ROOT, 'src/constants.js'))
? fs.readFileSync(path.join(ROOT, 'src/constants.js'), 'utf8') : null;
const Sbug = loadModule(src, constantsSrc);
const Sfix = loadModule(srcFixed, constantsSrc);
function erfcRef(x) { // Abramowitz & Stegun 7.1.26
const z = Math.abs(x);
const t = 1 / (1 + z / 2);
const r = t * Math.exp(-z * z - 1.26551223 + t * (1.00002368 +
t * (0.37409196 + t * (0.09678418 + t * (-0.18628806 +
t * (0.27886807 + t * (-1.13520398 + t * (1.48851587 +
t * (-0.82215223 + t * 0.17087277)))))))));
return x >= 0 ? r : 2 - r;
}
const Q15 = (x) => erfcRef(Math.sqrt(x)) + 2 * Math.sqrt(x / Math.PI) * Math.exp(-x);
function lrChi2(bits128) {
const v = [0, 0, 0, 0];
for (let i = 0; i < 16; i++) {
let cur = 0, l = 0;
for (let j = 0; j < 8; j++) {
if (bits128[i * 8 + j] === '1') { cur++; if (cur > l) l = cur; } else cur = 0;
}
v[l <= 1 ? 0 : (l === 2 ? 1 : (l === 3 ? 2 : 3))]++;
}
const r = 16, pi = [0.2148, 0.3672, 0.2305, 0.1875];
let x = 0;
for (let i = 0; i < 4; i++) x += Math.pow(v[i] - r * pi[i], 2) / (r * pi[i]);
return x;
}
const bitsOf = (b) => BigInt('0x' + b.toString('hex')).toString(2).padStart(256, '0');
const halvesChi2 = (b) => { const s = bitsOf(b); return [lrChi2(s.slice(0, 128)), lrChi2(s.slice(128))]; };
console.log('\n=== Part A: unit proof (real code) ===');
let refErr = null;
try { Sbug.igamc(1.5, 1.0); } catch (e) { refErr = e; }
console.log(`[A] S.igamc(1.5, 1.0) on real code -> ${refErr ? refErr.constructor.name + ': ' + refErr.message : 'no throw'}`);
if (!(refErr instanceof ReferenceError)) { console.log('RESULT: NOT REPRODUCED'); process.exit(1); }
const fixedVal = Sfix.igamc(1.5, 1.0);
const refVal = Q15(1.0);
console.log(`[A] fixed S.igamc(1.5, 1.0) = ${fixedVal.toFixed(12)} (reference ${refVal.toFixed(12)}, |d|=${Math.abs(fixedVal - refVal).toExponential(2)})`);
console.log(`\n=== Part B: pass-rate over ${N} CSPRNG buffers (real code) ===`);
function sweep(S, n, collect) {
let pass = 0, ref = 0, other = 0;
const ex = {};
for (let i = 0; i < n; i++) {
const b = crypto.randomBytes(32);
try { S.randomnessTest(b); pass++; if (collect && !ex.acc) ex.acc = Buffer.from(b); }
catch (e) {
if (e instanceof ReferenceError) { ref++; if (collect && !ex.rej) ex.rej = Buffer.from(b); }
else other++;
}
}
return { pass, ref, other, ex };
}
const rBug = sweep(Sbug, N, true);
const rFix = sweep(Sfix, N, false);
const pBug = rBug.pass / N, pFix = rFix.pass / N;
console.log(`[B] buggy code : pass=${rBug.pass}/${N} = ${pBug.toFixed(4)} (ReferenceError on ${rBug.ref} buffers = ${(100 * rBug.ref / N).toFixed(1)}%, explicit test-fail throws ${rBug.other})`);
console.log(`[B] fixed code : pass=${rFix.pass}/${N} = ${pFix.toFixed(4)} (intended design: ~0.96)`);
console.log(`[B] CSPRNG consumption per accepted buffer: buggy ${(1 / pBug).toFixed(2)}x vs fixed ${(1 / pFix).toFixed(3)}x -> amplification ${(pFix / pBug).toFixed(2)}x`);
console.log(`[B] min-entropy loss bound per 256-bit buffer: buggy ${(-Math.log2(pBug)).toFixed(2)} bits vs fixed ${(-Math.log2(pFix)).toFixed(3)} bits`);
console.log('\n=== Part C: exhibits ===');
const show = (tag, b) => {
const [c1, c2] = halvesChi2(b);
console.log(`[C] ${tag}: ${b.toString('hex')}`);
console.log(` chi2 halves = [${c1.toFixed(3)}, ${c2.toFixed(3)}], p-values = [${Q15(c1 / 2).toFixed(3)}, ${Q15(c2 / 2).toFixed(3)}]`);
};
if (rBug.ex.rej) show('rejected via ReferenceError (statistically EXCELLENT buffer, would pass the intended test)', rBug.ex.rej);
if (rBug.ex.acc) show('accepted by buggy code (moderately long runs: chi2 >= 3.0 in BOTH halves)', rBug.ex.acc);
console.log('\n=== Part D: real S.generateEntropy (silent, amplified CSPRNG use) ===');
function measureDraws(S, calls) {
let draws = 0;
const orig = S.getRandomValues;
S.getRandomValues = (buf) => { draws++; return orig(buf); };
let ok = 0;
for (let i = 0; i < calls; i++) { S.generateEntropy({ strength: 256, hex: false }); ok++; }
S.getRandomValues = orig;
return { calls, ok, draws, per: draws / calls };
}
const M = 2000;
const dBug = measureDraws(Sbug, M);
const dFix = measureDraws(Sfix, M);
console.log(`[D] buggy: ${dBug.ok}/${dBug.calls} generateEntropy calls succeeded silently, ${dBug.draws} CSPRNG draws (${dBug.per.toFixed(2)} per call)`);
console.log(`[D] fixed: ${dFix.ok}/${dFix.calls} calls, ${dFix.draws} draws (${dFix.per.toFixed(2)} per call)`);
const confirmed = (refErr instanceof ReferenceError) && pBug < 0.25 && pFix > 0.85 && dBug.per > 3;
console.log(`\nRESULT: ${confirmed ? 'VULNERABILITY CONFIRMED' : 'NOT REPRODUCED'}`);
process.exit(confirmed ? 0 : 1);
Proposed fix
Minimal (one token):
- if (x < 1.0 || x < a) return 1.0 - igam(a, x);
+ if (x < 1.0 || x < a) return 1.0 - S.igam(a, x);
Hardening recommendations:
- In
generateEntropy, catch only the test's explicit failure errors (dedicated error type, or rethrow ReferenceError) so a crashed check can never again masquerade as a failed check.
- Unit tests:
igamc(1.5, 1.0) ≈ 0.5724067, igamc(1.5, 1.5) ≈ 0.3916252; property test igamc(a,x) + igam(a,x) ≈ 1 over a grid.
- Regression test: mean CSPRNG draws per
generateEntropy() call ≈ 1.04 (fail if > 1.5).
- Rebuild
dist/jsbtc.web.min.js and the embedded bundle in mnemonic-offline-tool.
References
- Bug introduction:
009eb28 (2020-02-23); HEAD e070824 (2020-09-30); dist build 08803aa (2020-05-19).
- Challenge build:
bitaps-com/mnemonic-offline-tool@5b6dd99 (2020-06-19).
- NIST SP 800-22rev1a, §2.4 "Test for the Longest Run of Ones in a Block".
- Wayback Machine snapshots of the bitaps.com jsbtc bundle: 2020-08-22, 2020-11-24.
Filed under the Shamir Secret Backup Scheme Bug Bounty (https://bitaps.com/mnemonic/challenge). Discovered during legal local cryptanalysis of the public challenge artifacts; no interaction with bitaps.com infrastructure was involved.
Summary
S.igamc(a, x)insrc/functions/bip39_mnemonic.jscalls a bareigam(a, x)that does not exist in module scope (onlyS.igamis defined). Since commit009eb28, every call withx < 1.0 || x < athrows aReferenceErrorinstead of computing the complemented incomplete gamma function.generateEntropy()wraps the NIST SP 800-22 randomness test in a blankettry/catchand treats any exception — including thisReferenceError— as "randomness test failed, regenerate". As a result, the Test for the Longest Run of Ones in a Block is silently inverted: buffers whose chi² statistic is too good (chi² < 3.0 — the statistically best ~61% of all buffers) crash the test and are rejected, while only buffers with moderately long runs of ones (chi² ∈ [3.0054, 11.3424], p-value ∈ [0.01, 0.391] in both 128-bit halves) are accepted.The generator never crashes and produces normal-looking output, so the defect is invisible to users and downstream tests.
Root cause
Commit
009eb28(2020-02-23) refactored the module to attach every function to the exportedSobject. One call site was not updated:In the test (
S.randomnessTest, line 174):with
k = 3, so the buggy branch is taken wheneverx_sqrt < 3.0. The exception is swallowed one level up:The
catchwas meant for the test's explicit failure throws, but it also catches theReferenceError, conflating "test says the buffer is bad" with "test crashed". Rejection sampling then silently keeps only the survivors.Affected versions and products
009eb28(2020-02-23) → HEADe070824, package v1.0.17src/functions/bip39_mnemonic.js:62dist/jsbtc.web.min.js(incl. build08803aa, 2020-05-19)return 1-igam(i,e), noigamin bundle5b6dd99(2020-06-19), the build used for the 1 BTC challengedist/jsbtc.web.min.js@08803aareturn 1-igam(i,e)patternc0c9dcb(pre-2020-02-23)igam/igamc, call resolvesdef igamresolves under Python scopingImpact (measured on the real, unmodified module code)
randomnessTest: 0.141 (N=100 000) vs intended design 0.963 (one-token fix). Analytic estimate for the LR stage alone: P(3 ≤ chi²₃ ≤ 11.34)² ≈ 0.146.ReferenceErrorpath; rejection sampling masks it completely.generateEntropy()averages 6.9–7.3 draws per call (fixed: 1.04).randomnessTest(b)itself throwsReferenceError: igam is not definedon statistically excellent input — a plain correctness bug even when called directly.Exact acceptance window (dynamic-programming enumeration of all 969 block-count compositions, 409 pass): chi² per 128-bit half ∈ [3.0054, 11.3424], i.e. p-value ∈ [0.01, 0.3908]. An independent numpy replica of the buggy filter reproduces the real JS code on 50 000 labeled buffers with 0 mismatches.
Impact on the "1 BTC challenge" specifically
The challenge secret is one
generateEntropy({strength:128})output; coefficient/index buffers pass the same filter. Conditioning on the two published shares (x=3, x=15) and the GF(2⁸) consistency constraints, the bug reduces an attacker's search from 2¹²⁷·⁷ to ≈ 2¹²⁶ oracle calls (refined estimate H ≈ 123.9 bits). To be explicit: the challenge is not broken by this bug — the loss is real but small. The primary severity is the silent inversion of a security control in a crypto library, in every affected build, since 2020-02-23 and still at HEAD.Proof of concept
poc_igamc.jsloads the unmodifiedsrc/functions/bip39_mnemonic.js(+src/constants.js) from a local jsbtc checkout and runs four demonstrations:Actual output (HEAD
e070824):Reproduced identically on two independent checkouts (full-history clone at HEAD
e070824and archived snapshotjsbtc-e070824eb877).Full PoC source (poc_igamc.js)
Proposed fix
Minimal (one token):
Hardening recommendations:
generateEntropy, catch only the test's explicit failure errors (dedicated error type, or rethrowReferenceError) so a crashed check can never again masquerade as a failed check.igamc(1.5, 1.0) ≈ 0.5724067,igamc(1.5, 1.5) ≈ 0.3916252; property testigamc(a,x) + igam(a,x) ≈ 1over a grid.generateEntropy()call ≈ 1.04 (fail if > 1.5).dist/jsbtc.web.min.jsand the embedded bundle in mnemonic-offline-tool.References
009eb28(2020-02-23); HEADe070824(2020-09-30); dist build08803aa(2020-05-19).bitaps-com/mnemonic-offline-tool@5b6dd99(2020-06-19).Filed under the Shamir Secret Backup Scheme Bug Bounty (https://bitaps.com/mnemonic/challenge). Discovered during legal local cryptanalysis of the public challenge artifacts; no interaction with bitaps.com infrastructure was involved.