Skip to content

Commit

Permalink
Enhance: Ability to choose random number generator algorithm for `Mat…
Browse files Browse the repository at this point in the history
…h:gen_rng` (#731)

* Updated `vite.config.js`
  so that it matches the `tsconfig.json`

* Implemented unbiased variant of `Math:rnd` and `Math:gen_rng_unbiased`

* Updated `aiscript.api.md`

* * Refactored `Math:rnd` to use crypto
* Refactored `Math:rnd_unbiased` to use crypto
* Refactored `Math:gen_rng_unbiased` to use `SeedRandomWrapper`

* Updated `aiscript.api.md`

* * Removed `Math:rnd_unbiased`

* * Updated `CHANGELOG.md`
* Updated `std-math.md`

* Implemented `Math:gen_rng_chacha20`

* * Refactored `Math:gen_rng_unbiased` to use ChaCha20
* Refactored `Math:rnd` to use `crypto`
* Refactored `Math:rnd_unbiased` to use `crypto`

* + Added tests for `Math:gen_rng_unbiased`
* Reduced potential random test failure

* * `Math:gen_rng` now has 2nd parameter `algorithm`

* *Updated `CHANGELOG.md`

* Updated `std-math.md`

* Updated `package-lock.json`

* * Reverted `CHANGELOG.md` to match upstream

* Added `random algorithms.md`

* Updated std-math.md

* Added initial support for option objects in `Math:gen_rng`

* * Changed implementation of ChaCha20
* `Math:gen_rng` no longer accepts `str` options

* Updated `std-math.md`

* Updated `std-math.md`

* Fixed `Math:gen_rng` returned `Promise<VNativeFn | VNull>` instead of `VNativeFn | VNull`

* * Fixed ChaCha20 generating wrong values
* Fixed potential overflow in ChaCha20

* * `Math:gen_rng`: `options` no longer accepts anything but `obj` or `undefined`
* Invalid type for `seed` now throws exception in `Math:gen_rng`
  • Loading branch information
MineCake147E authored Aug 5, 2024
1 parent 6fafa1e commit a065fa1
Show file tree
Hide file tree
Showing 8 changed files with 441 additions and 15 deletions.
14 changes: 13 additions & 1 deletion docs/std-math.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,20 @@ _x_ +1の自然対数を計算します。
_min_ および _max_ を渡した場合、_min_ <= x, x <= _max_ の整数、
渡していない場合は 0 <= x, x < 1 の 小数が返されます。

### @Math:gen_rng(_seed_: num | str): fn
### @Math:gen_rng(_seed_: num | str, _options_?: obj): @(_min_?: num, _max_?: num)
シードから乱数生成機を生成します。
生成された乱数生成器は、_min_ および _max_ を渡した場合、_min_ <= x, x <= _max_ の整数、
渡していない場合は 0 <= x, x < 1 の浮動小数点数を返します。
_options_ に渡したオブジェクトを通じて、内部の挙動を指定できます。
`options.algorithm`の指定による挙動の変化は下記の通りです。
| `options.algorithm` | 内部の乱数生成アルゴリズム | 範囲指定整数生成アルゴリズム |
|--|--|--|
| `rc4` | RC4 | Rejection Sampling |
| `rc4_legacy` | RC4 | 浮動小数点数演算による範囲制限​(0.19.0以前のアルゴリズム) |
| 無指定 または 'chacha20' | ChaCha20 | Rejection Sampling |

> [!CAUTION]
> `rc4_legacy`等、浮動小数点数演算を伴う範囲指定整数生成アルゴリズムでは、演算時の丸め誤差により、指定した _max_ の値より大きな値が生成される可能性があります。
## その他
### @Math:clz32(_x_: num): num
Expand Down
40 changes: 26 additions & 14 deletions src/interpreter/lib/std.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
/* eslint-disable no-empty-pattern */
import { v4 as uuid } from 'uuid';
import seedrandom from 'seedrandom';
import { NUM, STR, FN_NATIVE, FALSE, TRUE, ARR, NULL, BOOL, OBJ, ERROR } from '../value.js';
import { assertNumber, assertString, assertBoolean, valToJs, jsToVal, assertFunction, assertObject, eq, expectAny, assertArray, reprValue } from '../util.js';
import { AiScriptRuntimeError, AiScriptUserError } from '../../error.js';
import { AISCRIPT_VERSION } from '../../constants.js';
import { textDecoder } from '../../const.js';
import { CryptoGen } from '../../utils/random/CryptoGen.js';
import { GenerateChaCha20Random, GenerateLegacyRandom, GenerateRC4Random } from '../../utils/random/genrng.js';
import type { Value } from '../value.js';

export const std: Record<string, Value> = {
Expand Down Expand Up @@ -453,23 +454,34 @@ export const std: Record<string, Value> = {

'Math:rnd': FN_NATIVE(([min, max]) => {
if (min && min.type === 'num' && max && max.type === 'num') {
return NUM(Math.floor(Math.random() * (Math.floor(max.value) - Math.ceil(min.value) + 1) + Math.ceil(min.value)));
const res = CryptoGen.instance.generateRandomIntegerInRange(min.value, max.value);
return res === null ? NULL : NUM(res);
}
return NUM(Math.random());
return NUM(CryptoGen.instance.generateNumber0To1());
}),

'Math:gen_rng': FN_NATIVE(([seed]) => {
'Math:gen_rng': FN_NATIVE(async ([seed, options]) => {
expectAny(seed);
if (seed.type !== 'num' && seed.type !== 'str') return NULL;

const rng = seedrandom(seed.value.toString());

return FN_NATIVE(([min, max]) => {
if (min && min.type === 'num' && max && max.type === 'num') {
return NUM(Math.floor(rng() * (Math.floor(max.value) - Math.ceil(min.value) + 1) + Math.ceil(min.value)));
}
return NUM(rng());
});
let algo = 'chacha20';
if (options?.type === 'obj') {
const v = options.value.get('algorithm');
if (v?.type !== 'str') throw new AiScriptRuntimeError('`options.algorithm` must be string.');
algo = v.value;
}
else if (options?.type !== undefined) {
throw new AiScriptRuntimeError('`options` must be an object if specified.');
}
if (seed.type !== 'num' && seed.type !== 'str' && seed.type !== 'null') throw new AiScriptRuntimeError('`seed` must be either number or string if specified.');
switch (algo) {
case 'rc4_legacy':
return GenerateLegacyRandom(seed);
case 'rc4':
return GenerateRC4Random(seed);
case 'chacha20':
return await GenerateChaCha20Random(seed);
default:
throw new AiScriptRuntimeError('`options.algorithm` must be one of these: `chacha20`, `rc4`, or `rc4_legacy`.');
}
}),
//#endregion

Expand Down
29 changes: 29 additions & 0 deletions src/utils/random/CryptoGen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { RandomBase, readBigUintLittleEndian } from './randomBase.js';

export class CryptoGen extends RandomBase {
private static _instance: CryptoGen = new CryptoGen();
public static get instance() : CryptoGen {
return CryptoGen._instance;
}

private constructor() {
super();
}

protected generateBigUintByBytes(bytes: number): bigint {
let u8a = new Uint8Array(Math.ceil(bytes / 8) * 8);
if (u8a.length < 1 || !Number.isSafeInteger(bytes)) return 0n;
u8a = this.generateBytes(u8a.subarray(0, bytes));
return readBigUintLittleEndian(u8a.buffer) ?? 0n;
}

public generateBigUintByBits(bits: number): bigint {
if (bits < 1 || !Number.isSafeInteger(bits)) return 0n;
const bytes = Math.ceil(bits / 8);
const wastedBits = BigInt(bytes * 8 - bits);
return this.generateBigUintByBytes(bytes) >> wastedBits;
}
public generateBytes(array: Uint8Array): Uint8Array {
return crypto.getRandomValues(array);
}
}
153 changes: 153 additions & 0 deletions src/utils/random/chacha20.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { RandomBase, readBigUintLittleEndian } from './randomBase.js';

// translated from https://github.com/skeeto/chacha-js/blob/master/chacha.js
const chacha20BlockSize = 64;
const CHACHA_ROUNDS = 20;
const CHACHA_KEYSIZE = 32;
const CHACHA_IVSIZE = 8;
function rotate(v: number, n: number): number { return (v << n) | (v >>> (32 - n)); }
function quarterRound(x: Uint32Array, a: number, b: number, c: number, d: number): void {
if (x.length < 16) return;
let va = x[a];
let vb = x[b];
let vc = x[c];
let vd = x[d];
if (va === undefined || vb === undefined || vc === undefined || vd === undefined) return;
va = (va + vb) | 0;
vd = rotate(vd ^ va, 16);
vc = (vc + vd) | 0;
vb = rotate(vb ^ vc, 12);
va = (va + vb) | 0;
vd = rotate(vd ^ va, 8);
vc = (vc + vd) | 0;
vb = rotate(vb ^ vc, 7);
x[a] = va;
x[b] = vb;
x[c] = vc;
x[d] = vd;
}
function generateChaCha20(dst: Uint32Array, state: Uint32Array) : void {
if (dst.length < 16 || state.length < 16) return;
dst.set(state);
for (let i = 0; i < CHACHA_ROUNDS; i += 2) {
quarterRound(dst, 0, 4, 8, 12);
quarterRound(dst, 1, 5, 9, 13);
quarterRound(dst, 2, 6, 10, 14);
quarterRound(dst, 3, 7, 11, 15);
quarterRound(dst, 0, 5, 10, 15);
quarterRound(dst, 1, 6, 11, 12);
quarterRound(dst, 2, 7, 8, 13);
quarterRound(dst, 3, 4, 9, 14);
}
for (let i = 0; i < 16; i++) {
let d = dst[i];
const s = state[i];
if (d === undefined || s === undefined) throw new Error('generateChaCha20: Something went wrong!');
d = (d + s) | 0;
dst[i] = d;
}
}
export class ChaCha20 extends RandomBase {
private keynonce: Uint32Array;
private state: Uint32Array;
private buffer: Uint8Array;
private filledBuffer: Uint8Array;
private counter: bigint;
constructor(seed?: Uint8Array | undefined) {
const keyNonceBytes = CHACHA_IVSIZE + CHACHA_KEYSIZE;
super();
let keynonce: Uint8Array;
if (typeof seed === 'undefined') {
keynonce = crypto.getRandomValues(new Uint8Array(keyNonceBytes));
} else {
keynonce = seed;
if (keynonce.byteLength > keyNonceBytes) keynonce = seed.subarray(0, keyNonceBytes);
if (keynonce.byteLength < keyNonceBytes) {
const y = new Uint8Array(keyNonceBytes);
y.set(keynonce);
keynonce = y;
}
}
const key = keynonce.subarray(0, CHACHA_KEYSIZE);
const nonce = keynonce.subarray(CHACHA_KEYSIZE, CHACHA_KEYSIZE + CHACHA_IVSIZE);
const kn = new Uint8Array(16 * 4);
kn.set([101, 120, 112, 97, 110, 100, 32, 51, 50, 45, 98, 121, 116, 101, 32, 107]);
kn.set(key, 4 * 4);
kn.set(nonce, 14 * 4);
this.keynonce = new Uint32Array(kn.buffer);
this.state = new Uint32Array(16);
this.buffer = new Uint8Array(chacha20BlockSize);
this.counter = 0n;
this.filledBuffer = new Uint8Array(0);
}
private fillBuffer(): void {
this.buffer.fill(0);
this.buffer = this.fillBufferDirect(this.buffer);
this.filledBuffer = this.buffer;
}
private fillBufferDirect(buffer: Uint8Array): Uint8Array {
if ((buffer.length % chacha20BlockSize) !== 0) throw new Error('ChaCha20.fillBufferDirect should always be called with the buffer with the length a multiple-of-64!');
buffer.fill(0);
let counter = this.counter;
const state = this.state;
const counterState = new BigUint64Array(state.buffer);
let dst = buffer;
while (dst.length > 0) {
const dbuf = dst.subarray(0, state.byteLength);
const dst32 = new Uint32Array(dbuf.buffer);
state.set(this.keynonce);
counterState[6] = BigInt.asUintN(64, counter);
generateChaCha20(dst32, state);
dst = dst.subarray(dbuf.length);
counter = BigInt.asUintN(64, counter + 1n);
}
this.counter = counter;
return buffer;
}

protected generateBigUintByBytes(bytes: number): bigint {
let u8a = new Uint8Array(Math.ceil(bytes / 8) * 8);
if (u8a.length < 1 || !Number.isSafeInteger(bytes)) return 0n;
u8a = this.generateBytes(u8a.subarray(0, bytes));
return readBigUintLittleEndian(u8a.buffer) ?? 0n;
}

public generateBigUintByBits(bits: number): bigint {
if (bits < 1 || !Number.isSafeInteger(bits)) return 0n;
const bytes = Math.ceil(bits / 8);
const wastedBits = BigInt(bytes * 8 - bits);
return this.generateBigUintByBytes(bytes) >> wastedBits;
}

public generateBytes(array: Uint8Array): Uint8Array {
if (array.length < 1) return array;
array.fill(0);
let dst = array;
if (dst.length <= this.filledBuffer.length) {
dst.set(this.filledBuffer.subarray(0, dst.length));
this.filledBuffer = this.filledBuffer.subarray(dst.length);
return array;
} else {
while (dst.length > 0) {
if (this.filledBuffer.length === 0) {
if (dst.length >= chacha20BlockSize) {
const df64 = dst.subarray(0, dst.length - (dst.length % chacha20BlockSize));
this.fillBufferDirect(df64);
dst = dst.subarray(df64.length);
continue;
}
this.fillBuffer();
}
if (dst.length <= this.filledBuffer.length) {
dst.set(this.filledBuffer.subarray(0, dst.length));
this.filledBuffer = this.filledBuffer.subarray(dst.length);
return array;
}
dst.set(this.filledBuffer);
dst = dst.subarray(this.filledBuffer.length);
this.fillBuffer();
}
return array;
}
}
}
48 changes: 48 additions & 0 deletions src/utils/random/genrng.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import seedrandom from 'seedrandom';
import { FN_NATIVE, NULL, NUM } from '../../interpreter/value.js';
import { SeedRandomWrapper } from './seedrandom.js';
import { ChaCha20 } from './chacha20.js';
import type { VNativeFn, VNull, Value } from '../../interpreter/value.js';
import { textEncoder } from '../../const.js';

export function GenerateLegacyRandom(seed: Value | undefined) : VNativeFn | VNull {
if (!seed || seed.type !== 'num' && seed.type !== 'str') return NULL;
const rng = seedrandom(seed.value.toString());
return FN_NATIVE(([min, max]) => {
if (min && min.type === 'num' && max && max.type === 'num') {
return NUM(Math.floor(rng() * (Math.floor(max.value) - Math.ceil(min.value) + 1) + Math.ceil(min.value)));
}
return NUM(rng());
});
}

export function GenerateRC4Random(seed: Value | undefined) : VNativeFn | VNull {
if (!seed || seed.type !== 'num' && seed.type !== 'str') return NULL;
const rng = new SeedRandomWrapper(seed.value);
return FN_NATIVE(([min, max]) => {
if (min && min.type === 'num' && max && max.type === 'num') {
const result = rng.generateRandomIntegerInRange(min.value, max.value);
return typeof result === 'number' ? NUM(result) : NULL;
}
return NUM(rng.generateNumber0To1());
});
}

export async function GenerateChaCha20Random(seed: Value | undefined) : Promise<VNativeFn | VNull> {
if (!seed || seed.type !== 'num' && seed.type !== 'str' && seed.type !== 'null') return NULL;
let actualSeed : Uint8Array | undefined = undefined;
if (seed.type === 'num')
{
actualSeed = new Uint8Array(await crypto.subtle.digest('SHA-384', new Uint8Array(new Float64Array([seed.value]))));
} else if (seed.type === 'str') {
actualSeed = new Uint8Array(await crypto.subtle.digest('SHA-384', new Uint8Array(textEncoder.encode(seed.value))));
}
const rng = new ChaCha20(actualSeed);
return FN_NATIVE(([min, max]) => {
if (min && min.type === 'num' && max && max.type === 'num') {
const result = rng.generateRandomIntegerInRange(min.value, max.value);
return typeof result === 'number' ? NUM(result) : NULL;
}
return NUM(rng.generateNumber0To1());
});
}
Loading

0 comments on commit a065fa1

Please sign in to comment.