@q-ching/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +33 -0
- package/dist/casting.d.ts +45 -0
- package/dist/casting.js +110 -0
- package/dist/casting.js.map +1 -0
- package/dist/engine.test.d.ts +1 -0
- package/dist/engine.test.js +74 -0
- package/dist/engine.test.js.map +1 -0
- package/dist/entropy/gesture.d.ts +26 -0
- package/dist/entropy/gesture.js +52 -0
- package/dist/entropy/gesture.js.map +1 -0
- package/dist/entropy/pool.d.ts +29 -0
- package/dist/entropy/pool.js +70 -0
- package/dist/entropy/pool.js.map +1 -0
- package/dist/entropy/qrng.d.ts +47 -0
- package/dist/entropy/qrng.js +130 -0
- package/dist/entropy/qrng.js.map +1 -0
- package/dist/hexagram-data.d.ts +2 -0
- package/dist/hexagram-data.js +1859 -0
- package/dist/hexagram-data.js.map +1 -0
- package/dist/hexagrams.d.ts +19 -0
- package/dist/hexagrams.js +90 -0
- package/dist/hexagrams.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/trigrams.d.ts +10 -0
- package/dist/trigrams.js +60 -0
- package/dist/trigrams.js.map +1 -0
- package/dist/types.d.ts +82 -0
- package/dist/types.js +10 -0
- package/dist/types.js.map +1 -0
- package/dist/util.d.ts +17 -0
- package/dist/util.js +79 -0
- package/dist/util.js.map +1 -0
- package/package.json +48 -0
- package/src/casting.ts +131 -0
- package/src/engine.test.ts +77 -0
- package/src/entropy/gesture.ts +57 -0
- package/src/entropy/pool.ts +74 -0
- package/src/entropy/qrng.ts +170 -0
- package/src/hexagram-data.ts +1863 -0
- package/src/hexagrams.ts +97 -0
- package/src/index.ts +33 -0
- package/src/trigrams.ts +66 -0
- package/src/types.ts +98 -0
- package/src/util.ts +83 -0
package/src/casting.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { Bit, CastMethod, HexBits, Line, LineValue, Reading } from './types.js';
|
|
2
|
+
import { BitReader } from './util.js';
|
|
3
|
+
import { EntropyPool } from './entropy/pool.js';
|
|
4
|
+
import { gatherEntropy, localCsprng, type QrngConfig, type QrngResult } from './entropy/qrng.js';
|
|
5
|
+
import { hexagramByBits } from './hexagrams.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Coin method (three coins). Each coin: heads = 3, tails = 2. Three fair bits
|
|
9
|
+
* decide the line. With h = number of heads, value = 6 + h, giving
|
|
10
|
+
* 6 (old yin) 1/8
|
|
11
|
+
* 7 (young yang) 3/8
|
|
12
|
+
* 8 (young yin) 3/8
|
|
13
|
+
* 9 (old yang) 1/8
|
|
14
|
+
*/
|
|
15
|
+
function coinLine(reader: BitReader): LineValue {
|
|
16
|
+
const h = reader.readBit() + reader.readBit() + reader.readBit();
|
|
17
|
+
return (6 + h) as LineValue;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Yarrow-stalk method. The traditional stalk ritual yields an asymmetric
|
|
22
|
+
* distribution that makes changing lines rarer:
|
|
23
|
+
* 6 (old yin) 1/16
|
|
24
|
+
* 7 (young yang) 5/16
|
|
25
|
+
* 8 (young yin) 7/16
|
|
26
|
+
* 9 (old yang) 3/16
|
|
27
|
+
* Drawn from four uniform bits (0..15), since 16 is a power of two.
|
|
28
|
+
*/
|
|
29
|
+
function yarrowLine(reader: BitReader): LineValue {
|
|
30
|
+
const v = reader.readBits(4); // 0..15, uniform
|
|
31
|
+
if (v === 0) return 6; // 1/16
|
|
32
|
+
if (v <= 3) return 9; // 3/16 (1,2,3)
|
|
33
|
+
if (v <= 8) return 7; // 5/16 (4,5,6,7,8)
|
|
34
|
+
return 8; // 7/16 (9..15)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface CastInput {
|
|
38
|
+
/** 'coin' (default) or 'yarrow'. */
|
|
39
|
+
method?: CastMethod;
|
|
40
|
+
/** Bytes captured from a human gesture (mouse, touch, motion, keystrokes). */
|
|
41
|
+
userEntropy?: Uint8Array | number[];
|
|
42
|
+
/**
|
|
43
|
+
* Quantum entropy: pass `true` to auto-gather, a QrngConfig to configure the
|
|
44
|
+
* gather, or a pre-fetched QrngResult[] (e.g. fetched server-side).
|
|
45
|
+
*/
|
|
46
|
+
qrng?: boolean | QrngConfig | QrngResult[];
|
|
47
|
+
/** Reproduce a prior cast from its seed (hex fingerprint). */
|
|
48
|
+
seed?: string;
|
|
49
|
+
/** Override the clock (mainly for tests/determinism). */
|
|
50
|
+
now?: () => number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const BYTES_TO_GATHER = 48;
|
|
54
|
+
const STREAM_BYTES = 64; // 6 lines need at most 24 bits; 64 bytes is generous headroom
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Cast a reading. Builds an entropy pool from the querent's gesture, optional
|
|
58
|
+
* quantum sources, and the local CSPRNG; squeezes a whitened stream; and draws
|
|
59
|
+
* six lines bottom -> top, deriving the primary hexagram, the changing lines,
|
|
60
|
+
* and the transformed hexagram.
|
|
61
|
+
*/
|
|
62
|
+
export async function cast(input: CastInput = {}): Promise<Reading> {
|
|
63
|
+
const method: CastMethod = input.method ?? 'coin';
|
|
64
|
+
const now = input.now ?? (() => Date.now());
|
|
65
|
+
|
|
66
|
+
let pool: EntropyPool;
|
|
67
|
+
if (input.seed) {
|
|
68
|
+
pool = EntropyPool.fromSeed(input.seed);
|
|
69
|
+
} else {
|
|
70
|
+
pool = new EntropyPool();
|
|
71
|
+
if (input.userEntropy && input.userEntropy.length) {
|
|
72
|
+
pool.absorb('gesture', input.userEntropy);
|
|
73
|
+
}
|
|
74
|
+
if (input.qrng) {
|
|
75
|
+
let results: QrngResult[];
|
|
76
|
+
if (Array.isArray(input.qrng)) {
|
|
77
|
+
results = input.qrng;
|
|
78
|
+
} else {
|
|
79
|
+
const config = input.qrng === true ? {} : input.qrng;
|
|
80
|
+
results = await gatherEntropy(BYTES_TO_GATHER, config);
|
|
81
|
+
}
|
|
82
|
+
for (const r of results) {
|
|
83
|
+
if (r.ok && r.bytes && r.bytes.length) pool.absorb(`qrng:${r.source}`, r.bytes);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Always fold in a fresh local CSPRNG draw and a timestamp salt.
|
|
87
|
+
pool.absorb('csprng', localCsprng(32));
|
|
88
|
+
pool.absorb('time', String(now()));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const stream = await pool.squeeze(STREAM_BYTES);
|
|
92
|
+
const reader = new BitReader(stream);
|
|
93
|
+
|
|
94
|
+
const lines: Line[] = [];
|
|
95
|
+
for (let i = 0; i < 6; i++) {
|
|
96
|
+
const value = method === 'coin' ? coinLine(reader) : yarrowLine(reader);
|
|
97
|
+
lines.push({
|
|
98
|
+
position: i + 1,
|
|
99
|
+
value,
|
|
100
|
+
yang: value === 7 || value === 9,
|
|
101
|
+
changing: value === 6 || value === 9,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const primaryBits = lines.map((l) => (l.yang ? 1 : 0)) as Bit[] as HexBits;
|
|
106
|
+
const primary = hexagramByBits(primaryBits);
|
|
107
|
+
|
|
108
|
+
const changingPositions = lines.filter((l) => l.changing).map((l) => l.position);
|
|
109
|
+
|
|
110
|
+
let transformed = null as Reading['transformed'];
|
|
111
|
+
if (changingPositions.length > 0) {
|
|
112
|
+
const tBits = lines.map((l) => {
|
|
113
|
+
if (l.changing) return (l.yang ? 0 : 1) as Bit; // old yang -> yin, old yin -> yang
|
|
114
|
+
return (l.yang ? 1 : 0) as Bit;
|
|
115
|
+
}) as Bit[] as HexBits;
|
|
116
|
+
transformed = hexagramByBits(tBits);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
method,
|
|
121
|
+
lines,
|
|
122
|
+
primary,
|
|
123
|
+
transformed,
|
|
124
|
+
changingPositions,
|
|
125
|
+
seed: await pool.fingerprint(),
|
|
126
|
+
sources: pool.transcript,
|
|
127
|
+
createdAt: new Date(now()).toISOString(),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export { coinLine, yarrowLine };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { cast } from './casting.js';
|
|
4
|
+
import { EntropyPool } from './entropy/pool.js';
|
|
5
|
+
import { HEXAGRAMS, hexagramByBits, hexagramByNumber, validateHexagrams } from './hexagrams.js';
|
|
6
|
+
import { BitReader } from './util.js';
|
|
7
|
+
import { coinLine, yarrowLine } from './casting.js';
|
|
8
|
+
|
|
9
|
+
test('dataset passes deterministic structural validation', () => {
|
|
10
|
+
const result = validateHexagrams();
|
|
11
|
+
assert.equal(result.ok, true, result.errors.join('\n'));
|
|
12
|
+
assert.equal(HEXAGRAMS.length, 64);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('every hexagram has complete prose', () => {
|
|
16
|
+
for (const h of HEXAGRAMS) {
|
|
17
|
+
assert.ok(h.judgment.length > 10, `#${h.number} judgment`);
|
|
18
|
+
assert.ok(h.image.length > 10, `#${h.number} image`);
|
|
19
|
+
assert.ok(h.gloss.length > 2, `#${h.number} gloss`);
|
|
20
|
+
assert.equal(h.lineTexts.length, 6, `#${h.number} lineTexts`);
|
|
21
|
+
for (const lt of h.lineTexts) assert.ok(lt.length > 3, `#${h.number} line text`);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('hexagram 1 is Qian (all yang), 2 is Kun (all yin)', () => {
|
|
26
|
+
assert.equal(hexagramByNumber(1).bits.join(''), '111111');
|
|
27
|
+
assert.equal(hexagramByNumber(2).bits.join(''), '000000');
|
|
28
|
+
assert.equal(hexagramByBits([1, 1, 1, 1, 1, 1]).number, 1);
|
|
29
|
+
assert.equal(hexagramByBits([0, 0, 0, 0, 0, 0]).number, 2);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('a seed reproduces an identical reading', async () => {
|
|
33
|
+
const a = await cast({ userEntropy: [1, 2, 3, 4, 5], now: () => 1000 });
|
|
34
|
+
const b = await cast({ seed: a.seed, now: () => 1000 });
|
|
35
|
+
assert.equal(a.primary.number, b.primary.number);
|
|
36
|
+
assert.equal(a.transformed?.number ?? null, b.transformed?.number ?? null);
|
|
37
|
+
assert.deepEqual(a.lines.map((l) => l.value), b.lines.map((l) => l.value));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('coin distribution ~ 1/8, 3/8, 3/8, 1/8', async () => {
|
|
41
|
+
const counts: Record<number, number> = { 6: 0, 7: 0, 8: 0, 9: 0 };
|
|
42
|
+
const N = 200_000;
|
|
43
|
+
// deterministic uniform bytes from the pool, expanded large
|
|
44
|
+
const stream = await EntropyPool.fromSeed('00'.repeat(32)).squeeze(Math.ceil((N * 3) / 8) + 64);
|
|
45
|
+
const reader = new BitReader(stream);
|
|
46
|
+
for (let i = 0; i < N; i++) counts[coinLine(reader)]++;
|
|
47
|
+
assert.ok(Math.abs(counts[6] / N - 1 / 8) < 0.01, `6: ${counts[6] / N}`);
|
|
48
|
+
assert.ok(Math.abs(counts[7] / N - 3 / 8) < 0.01, `7: ${counts[7] / N}`);
|
|
49
|
+
assert.ok(Math.abs(counts[8] / N - 3 / 8) < 0.01, `8: ${counts[8] / N}`);
|
|
50
|
+
assert.ok(Math.abs(counts[9] / N - 1 / 8) < 0.01, `9: ${counts[9] / N}`);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('yarrow distribution ~ 1/16, 5/16, 7/16, 3/16', async () => {
|
|
54
|
+
const counts: Record<number, number> = { 6: 0, 7: 0, 8: 0, 9: 0 };
|
|
55
|
+
const N = 200_000;
|
|
56
|
+
const stream = await EntropyPool.fromSeed('a5'.repeat(32)).squeeze(Math.ceil((N * 4) / 8) + 64);
|
|
57
|
+
const reader = new BitReader(stream);
|
|
58
|
+
for (let i = 0; i < N; i++) counts[yarrowLine(reader)]++;
|
|
59
|
+
assert.ok(Math.abs(counts[6] / N - 1 / 16) < 0.01, `6: ${counts[6] / N}`);
|
|
60
|
+
assert.ok(Math.abs(counts[7] / N - 5 / 16) < 0.01, `7: ${counts[7] / N}`);
|
|
61
|
+
assert.ok(Math.abs(counts[8] / N - 7 / 16) < 0.01, `8: ${counts[8] / N}`);
|
|
62
|
+
assert.ok(Math.abs(counts[9] / N - 3 / 16) < 0.01, `9: ${counts[9] / N}`);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('changing lines produce a distinct transformed hexagram', async () => {
|
|
66
|
+
// search seeds for a cast with changing lines
|
|
67
|
+
let found = false;
|
|
68
|
+
for (let i = 0; i < 50 && !found; i++) {
|
|
69
|
+
const r = await cast({ userEntropy: [i], now: () => i });
|
|
70
|
+
if (r.changingPositions.length > 0) {
|
|
71
|
+
assert.ok(r.transformed, 'should have transformed hexagram');
|
|
72
|
+
assert.notEqual(r.transformed!.number, r.primary.number);
|
|
73
|
+
found = true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
assert.ok(found, 'expected at least one changing-line cast in 50 tries');
|
|
77
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A platform-agnostic accumulator for human "gesture" entropy: pointer moves,
|
|
3
|
+
* touch paths, device-motion samples, keystroke timings — anything the
|
|
4
|
+
* querent's body does while they hold their question in mind.
|
|
5
|
+
*
|
|
6
|
+
* It does not try to estimate entropy precisely; it simply captures the raw
|
|
7
|
+
* float bytes of each sample. The EntropyPool hashes everything afterward, so
|
|
8
|
+
* over-capture is harmless and a few genuinely unpredictable bits (the timing
|
|
9
|
+
* jitter of a human hand) are what matter.
|
|
10
|
+
*/
|
|
11
|
+
export class GestureEntropy {
|
|
12
|
+
private buf: number[] = [];
|
|
13
|
+
private _count = 0;
|
|
14
|
+
|
|
15
|
+
/** Number of samples captured. */
|
|
16
|
+
get count(): number {
|
|
17
|
+
return this._count;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
private pushFloat(v: number): void {
|
|
21
|
+
if (!Number.isFinite(v)) v = 0;
|
|
22
|
+
const dv = new DataView(new ArrayBuffer(4));
|
|
23
|
+
dv.setFloat32(0, v, true);
|
|
24
|
+
this.buf.push(dv.getUint8(0), dv.getUint8(1), dv.getUint8(2), dv.getUint8(3));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Record a pointer/touch sample at (x, y) with a high-resolution timestamp. */
|
|
28
|
+
push(x: number, y: number, t: number): this {
|
|
29
|
+
this.pushFloat(x);
|
|
30
|
+
this.pushFloat(y);
|
|
31
|
+
this.pushFloat(t);
|
|
32
|
+
this._count += 1;
|
|
33
|
+
return this;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Record a single scalar (e.g. an accelerometer axis or a key interval). */
|
|
37
|
+
pushScalar(v: number): this {
|
|
38
|
+
this.pushFloat(v);
|
|
39
|
+
this._count += 1;
|
|
40
|
+
return this;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The captured bytes. */
|
|
44
|
+
get bytes(): Uint8Array {
|
|
45
|
+
return Uint8Array.from(this.buf);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A coarse 0..1 sense of "how much" the querent has stirred — for UI meters. */
|
|
49
|
+
get fill(): number {
|
|
50
|
+
return Math.min(1, this._count / 96);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
reset(): void {
|
|
54
|
+
this.buf = [];
|
|
55
|
+
this._count = 0;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { concatBytes, fromHex, sha256, toBytes, toHex } from '../util.js';
|
|
2
|
+
import type { EntropySourceRecord } from '../types.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* An entropy pool that absorbs bytes from many labelled sources (a human
|
|
6
|
+
* gesture, one or more quantum RNGs, a local CSPRNG) and squeezes a
|
|
7
|
+
* whitened, uniform byte stream out of them.
|
|
8
|
+
*
|
|
9
|
+
* Design: extract-then-expand, HKDF-style.
|
|
10
|
+
* PRK = SHA-256( source_1 || source_2 || ... ) (the "seed")
|
|
11
|
+
* output_i = SHA-256( PRK || counter_i ) (the expansion)
|
|
12
|
+
*
|
|
13
|
+
* No single source can bias the result, a dead QRNG can't block a cast, and
|
|
14
|
+
* the PRK is exposed as a hex `fingerprint()` so a reading is fully
|
|
15
|
+
* reproducible from its seed — the basis for shareable, auditable casts.
|
|
16
|
+
*/
|
|
17
|
+
export class EntropyPool {
|
|
18
|
+
private chunks: { label: string; bytes: Uint8Array }[] = [];
|
|
19
|
+
private _prk: Uint8Array | null = null;
|
|
20
|
+
|
|
21
|
+
/** Absorb bytes from a source. Chainable. */
|
|
22
|
+
absorb(label: string, data: Uint8Array | number[] | string): this {
|
|
23
|
+
if (this._prk) {
|
|
24
|
+
throw new Error('Cannot absorb into a pool created from a fixed seed.');
|
|
25
|
+
}
|
|
26
|
+
const bytes = toBytes(data);
|
|
27
|
+
if (bytes.length > 0) this.chunks.push({ label, bytes });
|
|
28
|
+
return this;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A transcript of what contributed entropy and how much. */
|
|
32
|
+
get transcript(): EntropySourceRecord[] {
|
|
33
|
+
if (this._prk) return [{ label: 'seed', bytes: this._prk.length }];
|
|
34
|
+
return this.chunks.map((c) => ({ label: c.label, bytes: c.bytes.length }));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private async prk(): Promise<Uint8Array> {
|
|
38
|
+
if (this._prk) return this._prk;
|
|
39
|
+
const all = concatBytes(this.chunks.map((c) => c.bytes));
|
|
40
|
+
this._prk = await sha256(all);
|
|
41
|
+
return this._prk;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The reproducible seed: hex of the extracted pseudo-random key. */
|
|
45
|
+
async fingerprint(): Promise<string> {
|
|
46
|
+
return toHex(await this.prk());
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Produce `numBytes` of uniform output, deterministic for a given PRK. */
|
|
50
|
+
async squeeze(numBytes: number): Promise<Uint8Array> {
|
|
51
|
+
const prk = await this.prk();
|
|
52
|
+
const out = new Uint8Array(numBytes);
|
|
53
|
+
let off = 0;
|
|
54
|
+
let counter = 0;
|
|
55
|
+
while (off < numBytes) {
|
|
56
|
+
const input = new Uint8Array(prk.length + 4);
|
|
57
|
+
input.set(prk, 0);
|
|
58
|
+
new DataView(input.buffer).setUint32(prk.length, counter, false);
|
|
59
|
+
counter += 1;
|
|
60
|
+
const block = await sha256(input);
|
|
61
|
+
const take = Math.min(block.length, numBytes - off);
|
|
62
|
+
out.set(block.subarray(0, take), off);
|
|
63
|
+
off += take;
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Reconstruct a pool from a previously emitted seed (hex of the PRK). */
|
|
69
|
+
static fromSeed(seedHex: string): EntropyPool {
|
|
70
|
+
const pool = new EntropyPool();
|
|
71
|
+
pool._prk = fromHex(seedHex);
|
|
72
|
+
return pool;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { fromHex, getCrypto } from '../util.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Quantum / true random number sources.
|
|
5
|
+
*
|
|
6
|
+
* Honest note: a local CSPRNG is already statistically perfect for casting an
|
|
7
|
+
* oracle. The quantum sources are here for *meaning* — your hexagram drawn
|
|
8
|
+
* from vacuum fluctuations and an atmospheric hiss — and for transparency, not
|
|
9
|
+
* because they are "more random". Every gather always folds in the local
|
|
10
|
+
* CSPRNG so a cast can never be blocked or biased by a flaky remote API.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type QrngSourceName = 'nist' | 'anu' | 'random.org' | 'csprng';
|
|
14
|
+
|
|
15
|
+
export interface QrngResult {
|
|
16
|
+
source: QrngSourceName;
|
|
17
|
+
ok: boolean;
|
|
18
|
+
bytes: Uint8Array | null;
|
|
19
|
+
detail?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface QrngConfig {
|
|
23
|
+
/** Which sources to attempt. Default: ['nist', 'csprng']. csprng is always included. */
|
|
24
|
+
sources?: QrngSourceName[];
|
|
25
|
+
anuApiKey?: string;
|
|
26
|
+
randomOrgApiKey?: string;
|
|
27
|
+
/** Inject a fetch implementation (defaults to global fetch). */
|
|
28
|
+
fetchImpl?: typeof fetch;
|
|
29
|
+
/** Per-request timeout in ms (default 6000). */
|
|
30
|
+
timeoutMs?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function localCsprng(numBytes: number): Uint8Array {
|
|
34
|
+
const out = new Uint8Array(numBytes);
|
|
35
|
+
getCrypto().getRandomValues(out);
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getFetch(config: QrngConfig): typeof fetch {
|
|
40
|
+
const f = config.fetchImpl ?? (globalThis as { fetch?: typeof fetch }).fetch;
|
|
41
|
+
if (!f) throw new Error('No fetch implementation available.');
|
|
42
|
+
return f;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function withTimeout<T>(p: (signal: AbortSignal) => Promise<T>, ms: number): Promise<T> {
|
|
46
|
+
const controller = new AbortController();
|
|
47
|
+
const timer = setTimeout(() => controller.abort(), ms);
|
|
48
|
+
try {
|
|
49
|
+
return await p(controller.signal);
|
|
50
|
+
} finally {
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* NIST Randomness Beacon (v2). Public, keyless, quantum-seeded. Publishes a
|
|
57
|
+
* 512-bit value every 60s — the same for everyone in a given minute, which is
|
|
58
|
+
* its own kind of poetry: the cosmic pulse at the moment you asked.
|
|
59
|
+
*/
|
|
60
|
+
export async function fetchNistBeacon(numBytes: number, config: QrngConfig = {}): Promise<QrngResult> {
|
|
61
|
+
const fetchImpl = getFetch(config);
|
|
62
|
+
try {
|
|
63
|
+
const res = await withTimeout(
|
|
64
|
+
(signal) =>
|
|
65
|
+
fetchImpl('https://beacon.nist.gov/beacon/2.0/pulse/last', { signal }),
|
|
66
|
+
config.timeoutMs ?? 6000,
|
|
67
|
+
);
|
|
68
|
+
if (!res.ok) return { source: 'nist', ok: false, bytes: null, detail: `HTTP ${res.status}` };
|
|
69
|
+
const json = (await res.json()) as { pulse?: { outputValue?: string } };
|
|
70
|
+
const hex = json?.pulse?.outputValue;
|
|
71
|
+
if (!hex) return { source: 'nist', ok: false, bytes: null, detail: 'no outputValue' };
|
|
72
|
+
const full = fromHex(hex);
|
|
73
|
+
return { source: 'nist', ok: true, bytes: full.subarray(0, numBytes) };
|
|
74
|
+
} catch (err) {
|
|
75
|
+
return { source: 'nist', ok: false, bytes: null, detail: String(err) };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* ANU Quantum Random Numbers — vacuum fluctuations. The classic public
|
|
81
|
+
* endpoint is now rate-limited and may require an API key; we pass one if
|
|
82
|
+
* provided and fail gracefully otherwise.
|
|
83
|
+
*/
|
|
84
|
+
export async function fetchAnu(numBytes: number, config: QrngConfig = {}): Promise<QrngResult> {
|
|
85
|
+
const fetchImpl = getFetch(config);
|
|
86
|
+
try {
|
|
87
|
+
const url = `https://qrng.anu.edu.au/API/jsonI.php?length=${numBytes}&type=uint8`;
|
|
88
|
+
const headers: Record<string, string> = {};
|
|
89
|
+
if (config.anuApiKey) headers['x-api-key'] = config.anuApiKey;
|
|
90
|
+
const res = await withTimeout(
|
|
91
|
+
(signal) => fetchImpl(url, { headers, signal }),
|
|
92
|
+
config.timeoutMs ?? 6000,
|
|
93
|
+
);
|
|
94
|
+
if (!res.ok) return { source: 'anu', ok: false, bytes: null, detail: `HTTP ${res.status}` };
|
|
95
|
+
const json = (await res.json()) as { success?: boolean; data?: number[] };
|
|
96
|
+
if (!json?.success || !Array.isArray(json.data)) {
|
|
97
|
+
return { source: 'anu', ok: false, bytes: null, detail: 'unsuccessful response' };
|
|
98
|
+
}
|
|
99
|
+
return { source: 'anu', ok: true, bytes: Uint8Array.from(json.data.slice(0, numBytes)) };
|
|
100
|
+
} catch (err) {
|
|
101
|
+
return { source: 'anu', ok: false, bytes: null, detail: String(err) };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** RANDOM.ORG atmospheric noise (JSON-RPC v4). Requires an API key. */
|
|
106
|
+
export async function fetchRandomOrg(numBytes: number, config: QrngConfig = {}): Promise<QrngResult> {
|
|
107
|
+
const fetchImpl = getFetch(config);
|
|
108
|
+
if (!config.randomOrgApiKey) {
|
|
109
|
+
return { source: 'random.org', ok: false, bytes: null, detail: 'no API key' };
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
const body = {
|
|
113
|
+
jsonrpc: '2.0',
|
|
114
|
+
method: 'generateIntegers',
|
|
115
|
+
params: { apiKey: config.randomOrgApiKey, n: numBytes, min: 0, max: 255, replacement: true },
|
|
116
|
+
id: 1,
|
|
117
|
+
};
|
|
118
|
+
const res = await withTimeout(
|
|
119
|
+
(signal) =>
|
|
120
|
+
fetchImpl('https://api.random.org/json-rpc/4/invoke', {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
headers: { 'content-type': 'application/json' },
|
|
123
|
+
body: JSON.stringify(body),
|
|
124
|
+
signal,
|
|
125
|
+
}),
|
|
126
|
+
config.timeoutMs ?? 6000,
|
|
127
|
+
);
|
|
128
|
+
if (!res.ok) return { source: 'random.org', ok: false, bytes: null, detail: `HTTP ${res.status}` };
|
|
129
|
+
const json = (await res.json()) as { result?: { random?: { data?: number[] } } };
|
|
130
|
+
const data = json?.result?.random?.data;
|
|
131
|
+
if (!Array.isArray(data)) {
|
|
132
|
+
return { source: 'random.org', ok: false, bytes: null, detail: 'no data' };
|
|
133
|
+
}
|
|
134
|
+
return { source: 'random.org', ok: true, bytes: Uint8Array.from(data) };
|
|
135
|
+
} catch (err) {
|
|
136
|
+
return { source: 'random.org', ok: false, bytes: null, detail: String(err) };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Attempt every configured source concurrently and always include the local
|
|
142
|
+
* CSPRNG. Returns one result per attempted source (failures included, so the
|
|
143
|
+
* UI can show what answered the call).
|
|
144
|
+
*/
|
|
145
|
+
export async function gatherEntropy(numBytes: number, config: QrngConfig = {}): Promise<QrngResult[]> {
|
|
146
|
+
const requested = config.sources ?? ['nist', 'csprng'];
|
|
147
|
+
const set = new Set<QrngSourceName>(requested);
|
|
148
|
+
set.add('csprng'); // guaranteed fallback, always present
|
|
149
|
+
|
|
150
|
+
const jobs: Promise<QrngResult>[] = [];
|
|
151
|
+
for (const source of set) {
|
|
152
|
+
switch (source) {
|
|
153
|
+
case 'nist':
|
|
154
|
+
jobs.push(fetchNistBeacon(numBytes, config));
|
|
155
|
+
break;
|
|
156
|
+
case 'anu':
|
|
157
|
+
jobs.push(fetchAnu(numBytes, config));
|
|
158
|
+
break;
|
|
159
|
+
case 'random.org':
|
|
160
|
+
jobs.push(fetchRandomOrg(numBytes, config));
|
|
161
|
+
break;
|
|
162
|
+
case 'csprng':
|
|
163
|
+
jobs.push(
|
|
164
|
+
Promise.resolve({ source: 'csprng' as const, ok: true, bytes: localCsprng(numBytes) }),
|
|
165
|
+
);
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return Promise.all(jobs);
|
|
170
|
+
}
|