brave-crypto
Version:
Crypto utils for Brave Browser
430 lines (387 loc) • 13.8 kB
JavaScript
const assert = require('assert')
const crypto = require('../index')
const nacl = require('tweetnacl')
const test = require('tape')
/*
* We apply a psi test to various distributions related to our uniform
* sampler.
*
* The psi test, also known as the G-test, is similar to the chi^2
* test, and is preferred by everyone's favourite Bayesian polemicist
* Jaynes, who discusses it in Secs. 9.11.1--9.12 of his book on
* pp. 296--301.
*
* Scaled appropriately, the psi test statistic converges to a chi^2
* distribution as the number of samples grows without bound, so we
* can use standard tables of chi^2 critical values. I picked 100
* degrees of freedom because that's the highest number in the NIST's
* table:
*
* NIST/SEMATECH e-Handbook of Statistical Methods,
* Sec. 1.3.6.7.4: Critical Values of the Chi-Square Distribution,
* <https://www.itl.nist.gov/div898/handbook/eda/section3/eda3674.htm>,
* April 2012, retrieved 2018-07-23.
*
* We choose a significance level alpha = 0.01, meaning there is a 1%
* probability of spuriously reporting failure for any individual
* trial of a psi test. Since there are many tests in this suite, the
* probability of a spurious test _suite_ failure in n cases is
*
* 1 - Binom(0; n, alpha) = 1 - (1 - alpha)^n,
*
* which grows rapidly as n grows. To keep it smaller, at some cost
* in statistical power to detect errors, we further try each psi test
* for t trials and allow the test to pass as long as at least k
* trials pass, so that it is as if each test's spurious failure
* probability were
*
* 1 - \sum_{i=k}^t Binom(i; t, alpha).
*
* In the case of t = 2 trials of which k = 1 must pass, this is
* alpha^2, and the spurious probability failure n tests is
*
* 1 - (1 - alpha^2)^n.
*
* With fifty tests, this is 1 - (1 - 0.0001)^50 < .499%.
*
* NOTE: Some of these tests have the reverse sense: rather than being
* hypothesis tests trying to reject the null hypothesis of working
* code, they are hypothesis tests trying to reject the null
* hypothesis of _specific bugs_ which serve as alternative hypotheses
* for the ordinary tests, and as such their significance level is the
* complement of the _statistical power_ of the ordinary tests. We do
* this to test the tests for whether they actually test anything.
*
* I don't know what that statistical power is -- it may be easy to
* compute in some cases, but often it isn't known analytically even
* by Serious Statisticians with strings of letters after their names.
* Empirically, with dozens of runs, it seems to be high enough, and
* likely exceeds 99%.
*/
const NSAMPLES = 100000
const DF = 100
const CHI2_CRITICAL = 135.807
const NPASSES_MIN = 1
const NTRIALS = 2
function trials (t, name, ntrials, npassesMin, f) {
let npass = 0
let trial
for (trial = 0; npass < npassesMin && trial < ntrials; trial++) {
if (f()) {
npass++
}
}
t.ok(npass >= npassesMin, `${npass} of ${trial} ${name} trials`)
}
function psi (C, P, n) {
assert.strictEqual(DF, C.length)
assert.strictEqual(DF, P.length)
let psi = 0
for (let i = 0; i < DF; i++) {
if (C[i] === 0) {
continue
}
assert(C[i] > 0)
psi += C[i] * Math.log(C[i] / (n * P[i]))
}
psi *= 2
return psi
}
function psiTest (t, probability, sample) {
t.plan(1)
const P = new Float64Array(DF) // probability
for (let i = 0; i < DF; i++) {
P[i] = probability(i)
}
trials(t, 'psi', NTRIALS, NPASSES_MIN, () => {
const C = new Uint32Array(DF) // count
for (let s = 0; s < NSAMPLES; s++) {
C[sample()]++
}
return psi(C, P, NSAMPLES) <= CHI2_CRITICAL
})
}
function psiTestReject (t, probability, sample) {
t.plan(1)
const P = new Float64Array(DF) // probability
for (let i = 0; i < DF; i++) {
P[i] = probability(i)
}
trials(t, 'psi reject', NTRIALS, NTRIALS - NPASSES_MIN + 1, () => {
const C = new Uint32Array(DF) // count
for (let s = 0; s < NSAMPLES; s++) {
C[sample()]++
}
return psi(C, P, NSAMPLES) > CHI2_CRITICAL
})
}
// Like uniform, but with a bug: wrong shift.
function baduniform (n) {
if (typeof n !== 'number' || n % 1 !== 0 || n <= 0 || n > (2 ** 53)) {
throw new Error('Bound must be positive integer at most 2^53.')
}
const min = (2 ** 53) % n
let x
do {
const b = nacl.randomBytes(7)
const l32 = b[0] | (b[1] << 8) | (b[2] << 16) | (b[3] << 26)
const h21 = b[4] | (b[5] << 8) | ((b[6] & 0x1f) << 16)
x = (2 ** 32) * h21 + l32
} while (x < min)
return x % n
}
const ERRPAT = /Bound must be positive integer at most 2\^53\./
test('uniform() throws', (t) => {
t.plan(1)
t.throws(() => crypto.random.uniform(), ERRPAT)
})
test("uniform('foo') throws", (t) => {
t.plan(1)
t.throws(() => crypto.random.uniform('foo'), ERRPAT)
})
test('uniform(0) throws', (t) => {
t.plan(1)
t.throws(() => crypto.random.uniform(0), ERRPAT)
})
test('uniform(0.5) throws', (t) => {
t.plan(1)
t.throws(() => crypto.random.uniform(0.5), ERRPAT)
})
// round(2**53 + 1) = 2**53, but round(2**53 + 2) > 2**53
test('uniform(2**53 + 2) throws', (t) => {
t.plan(1)
t.throws(() => crypto.random.uniform((2 ** 53) + 2), ERRPAT)
})
test('uniform(1) yields 0', (t) => {
t.plan(1)
t.equal(0, crypto.random.uniform(1))
})
test('uniform(2**53) does not throw', (t) => {
t.plan(1)
t.doesNotThrow(() => crypto.random.uniform(2 ** 53))
})
test('uniform(DF) passes psi test for uniform distribution', (t) => {
psiTest(t, i => 1 / DF, () => crypto.random.uniform(DF))
})
// Empirically confirm that the psi test has enough statistical power
// to detect modulo bias in the above test.
test('uniform(2*DF + 1) % DF fails psi test for uniform distribution', (t) => {
psiTestReject(t, i => 1 / DF, () => crypto.random.uniform((2 * DF) + 1) % DF)
})
test('uniform(256) % DF passes psi test for modulo bias', (t) => {
psiTest(t, i => (Math.floor(256 / DF) + (i < 256 % DF)) / 256, () => {
return crypto.random.uniform(256) % DF
})
})
// Why do we test the bits? Because I made this error:
//
// const l32 = b[0] | (b[1] << 8) | (b[2] << 16) | (b[3] << 26)
//
// And it was _not_ caught by the above uniform tests.
test('bits [24..32) of bad uniform fail psi test for uniform distribution', (t) => {
psiTestReject(t, i => (Math.floor(256 / DF) + (i < 256 % DF)) / 256, () => {
return (0xff & Math.floor(baduniform(2 ** 53) / (2 ** 24))) % DF
})
})
for (let b = 0; b < 53 - 8; b += 8) {
test(`bits [${b}..${b + 8}) of uniform(2**53) pass psi test for uniform distribution`, (t) => {
psiTest(t, i => (Math.floor(256 / DF) + (i < 256 % DF)) / 256, () => {
const x = crypto.random.uniform(2 ** 53)
return (0xff & Math.floor(x / (2 ** b))) % DF
})
})
}
test('bits [45..53) of uniform(2**53) pass psi test for uniform distribution', (t) => {
psiTest(t, i => (Math.floor(256 / DF) + (i < 256 % DF)) / 256, () => {
return (0xff & Math.floor(crypto.random.uniform(2 ** 53) / (2 ** 45))) % DF
})
})
// Like uniform_01, but limited to binary16 numbers with 11 bits of
// precision.
function uniform_01_lowprec () { // eslint-disable-line camelcase
function uniform16 () {
const b = nacl.randomBytes(2)
return (b[0] | (b[1] << 8)) >>> 0
}
// Draw an exponent with geometric distribution. Here emin = -14,
// so 16 bits is plenty.
const e = Math.clz32(uniform16()) - 16
// Draw normal odd 16-bit significand with uniform distribution.
const s0 = (uniform16() | 0x8001) >>> 0
// Round to an 11-bit significand in [2^15, 2^16], yielding a
// significand that is a multiple of 2^(16 - 11) = 2^5.
const hack = 3 * (2 ** (16 - 11 + 53 - 2))
const s = (s0 + hack) - hack
// Scale into [1/2, 1] and apply the exponent.
return s * (2 ** (-16 - e))
}
// Like uniform_01_lowprec, but with a bug: numbers <2^-11 excluded,
// as if you used the naive approach for sampling IEEE 754-2008
// binary16 numbers in [0,1] that many people use for binary64
// numbers.
function baduniform_01_lowprec () { // eslint-disable-line camelcase
return crypto.random.uniform(2 ** 11) / (2 ** 11)
}
// Like uniform_01, but with a bug: wrong shift amount.
function baduniform_01_badshift () { // eslint-disable-line camelcase
function uniform32 () {
const b = nacl.randomBytes(4)
return (b[0] | (b[1] << 8) | (b[2] << 16) | (b[3] << 25)) >>> 0
}
// Draw an exponent with geometric distribution.
let e = 0
let x
while (e < 1088) {
if ((x = uniform32()) !== 0) {
break
}
e += 32
}
e += Math.clz32(x)
// Draw normal odd 64-bit significand with uniform distribution.
const hi = (uniform32() | 0x80000000) >>> 0
const lo = (uniform32() | 0x00000001) >>> 0
// Assemble parts into [2^63, 2^64) with uniform distribution.
// Using an odd low part breaks ties in the rounding, which should
// occur only in a set of measure zero.
const s = hi * (2 ** 32) + lo
// Scale into [1/2, 1) and apply the exponent.
return s * (2 ** (-64 - e))
}
function reject (x0, f) {
let x
do {
x = f()
} while (x === x0)
return x
}
// It had better appear uniformly distributed to psi. The
// distribution is not exact, but the error of each bucket's
// probability from 1/DF is so small it is insignificant here.
test('uniform_01() passes psi test for uniformly spaced buckets', (t) => {
psiTest(t, i => 1 / DF, () => {
return Math.floor(reject(1, crypto.random.uniform_01) * DF)
})
})
// dist16[i] = Pr[i/100 <= min(fp16(U), 99) < (i + 1)/100], where
// fp16(U) is the standard rounding to a binary16 floating-point
// number of a uniform random real in [0,1].
const dist16 = [
9.993438720703124e-3, 9.993438720703124e-3,
0.0100048828125, 0.00998199462890625,
0.0100048828125, 0.0100048828125,
0.0099591064453125, 0.0100048828125,
0.0100048828125, 0.0100048828125,
0.0100048828125, 0.0100048828125,
0.009913330078125, 0.0100048828125,
0.0100048828125, 0.0100048828125,
0.0100048828125, 0.0100048828125,
0.0100048828125, 0.0100048828125,
0.0100048828125, 0.0100048828125,
0.0100048828125, 0.0100048828125,
0.00994384765625, 0.0098828125,
0.0100048828125, 0.0100048828125,
0.0100048828125, 0.0100048828125,
0.0100048828125, 0.0100048828125,
0.0100048828125, 0.0100048828125,
0.0100048828125, 0.0100048828125,
0.0100048828125, 0.0100048828125,
0.0100048828125, 0.0100048828125,
0.0100048828125, 0.0100048828125,
0.0100048828125, 0.0100048828125,
0.0100048828125, 0.0100048828125,
0.0100048828125, 0.0100048828125,
0.0100048828125, 0.0098828125,
0.0098828125, 0.0098828125,
0.010126953125, 0.0098828125,
0.010126953125, 0.0098828125,
0.010126953125, 0.0098828125,
0.010126953125, 0.0098828125,
0.010126953125, 0.0098828125,
0.010126953125, 0.0098828125,
0.010126953125, 0.0098828125,
0.010126953125, 0.0098828125,
0.010126953125, 0.0098828125,
0.010126953125, 0.0098828125,
0.010126953125, 0.0098828125,
0.010126953125, 0.0098828125,
0.0098828125, 0.010126953125,
0.0098828125, 0.010126953125,
0.0098828125, 0.010126953125,
0.0098828125, 0.010126953125,
0.0098828125, 0.010126953125,
0.0098828125, 0.010126953125,
0.0098828125, 0.010126953125,
0.0098828125, 0.010126953125,
0.0098828125, 0.010126953125,
0.0098828125, 0.010126953125,
0.0098828125, 0.010126953125,
0.0098828125, 0.01037109375
]
// The low-precision variant had better appear uniformly distributed
// to psi, at least as uniform as binary16 floating-point arithmetic
// can be, which is nonuniform enough we need to compute it more
// precisely in the dist16 table.
test('uniform_01_lowprec() passes psi test for uniformly spaced buckets', (t) => {
psiTest(t, i => dist16[i], () => {
const x = uniform_01_lowprec()
for (let i = 0; i < DF; i++) {
if (x < (i + 1) / DF) {
return i
}
}
assert(x === 1)
return DF - 1
})
})
// Test that in 100000 samples we get at least one nonzero sample
// below 2^-11 is somewhere between 2^-11 and 2^-12, say 2^-12 to be
// conservative; the probability of a sample failing this criterion is
// then at most 1 - 2^-12; the probability of _all_ samples failing
// this criterion, i.e. a spurious failure of the test, is at most
//
// (1 - 2^-12)^100000 ~= 2 * 10^-11 < 10^-10.
//
// This is not zero, but it's close enough for a test suite like this,
// and far below the spurious failure probability of 0.0001 for other
// tests here!
test('uniform_01_lowprec() passes small number test', (t) => {
t.plan(1)
trials(t, 'small number', NTRIALS, NPASSES_MIN, () => {
for (let i = 0; i < NSAMPLES; i++) {
const x = uniform_01_lowprec()
if (x > 0 && x < 2 ** -11) {
return true
}
}
return false
})
})
// baduniform_01_lowprec() may return 0, but it will never return
// anything 0 < x < 2^-11.
test('baduniform_01_lowprec() fails small number test', (t) => {
t.plan(1)
trials(t, 'small number', NTRIALS, NTRIALS - NPASSES_MIN + 1, () => {
for (let i = 0; i < NSAMPLES; i++) {
const x = baduniform_01_lowprec()
if (x > 0 && x < 2 ** -11) {
return false
}
}
return true
})
})
// If we discard some bits of the full-precision uniform_01(), it
// should continue to pass psi.
test('(uniform_01()*64)%1 passes psi test for uniformly spaced buckets', (t) => {
psiTest(t, i => 1 / DF, () => {
return Math.floor(((reject(1, crypto.random.uniform_01) * 64) % 1) * DF)
})
})
// Another pathology.
test('baduniform_01_badshift() fails psi test for uniformly spaced buckets', (t) => {
psiTestReject(t, i => 1 / DF, () => {
return Math.floor(reject(1, baduniform_01_badshift) * DF)
})
})