blockyard 0.0.1 → 0.0.9
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/CHANGELOG.md +679 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +172 -4
- package/SECURITY.md +38 -0
- package/bin/blockyard.js +40 -0
- package/config/pool-map.json +2620 -0
- package/docs/API.md +1575 -0
- package/docs/ARCHITECTURE.md +1307 -0
- package/docs/AUTO-UPDATE.md +269 -0
- package/docs/CONFIGURATION.md +840 -0
- package/docs/DEFECTS.md +813 -0
- package/docs/EFFECTS-AGENTS.md +448 -0
- package/docs/GETTING-STARTED.md +202 -0
- package/docs/INSTALL.md +490 -0
- package/docs/MEASUREMENTS.md +1254 -0
- package/docs/PRIVATE-LEADERBOARD.md +230 -0
- package/docs/RULES.md +681 -0
- package/docs/SECURITY-AUDIT-2026-09-14.md +177 -0
- package/docs/SECURITY-AUDIT.md +258 -0
- package/docs/SECURITY.md +195 -0
- package/docs/STATE-2026-09-09.md +200 -0
- package/docs/TROUBLESHOOTING.md +298 -0
- package/docs/USER-GUIDE.md +1022 -0
- package/package.json +53 -5
- package/public/404.html +9 -0
- package/public/css/app.css +1785 -0
- package/public/index.html +893 -0
- package/public/js/about.js +112 -0
- package/public/js/agents.js +964 -0
- package/public/js/app.js +1312 -0
- package/public/js/arkanoid.js +806 -0
- package/public/js/blockanoid.js +347 -0
- package/public/js/blockout.js +347 -0
- package/public/js/blockpack.js +428 -0
- package/public/js/blockscene3d.js +2678 -0
- package/public/js/breakout.js +224 -0
- package/public/js/charts.js +635 -0
- package/public/js/depthchart.js +311 -0
- package/public/js/details3d.js +2957 -0
- package/public/js/explorer.js +405 -0
- package/public/js/feepalette.js +149 -0
- package/public/js/fmt.js +162 -0
- package/public/js/goggles.js +886 -0
- package/public/js/kiosk.js +41 -0
- package/public/js/login.js +83 -0
- package/public/js/markets.js +357 -0
- package/public/js/mining.js +1138 -0
- package/public/js/panels.js +966 -0
- package/public/js/pricechart.js +188 -0
- package/public/js/settings.js +1014 -0
- package/public/js/tetris.js +226 -0
- package/public/js/tetrust.js +356 -0
- package/public/js/tetsound.js +175 -0
- package/public/login.html +33 -0
- package/scripts/blockfile-measure.js +156 -0
- package/scripts/browser-check.mjs +286 -0
- package/scripts/check.js +173 -0
- package/scripts/decode-check.js +81 -0
- package/scripts/doc-counts.js +109 -0
- package/scripts/donate-qr.py +20 -0
- package/scripts/fake-node.js +534 -0
- package/scripts/index-bench.js +216 -0
- package/scripts/index-benchmark.js +117 -0
- package/scripts/index-build.js +40 -0
- package/scripts/live-render-check.mjs +89 -0
- package/scripts/manage-users.js +132 -0
- package/scripts/motion-check.mjs +138 -0
- package/scripts/pool-map.js +157 -0
- package/scripts/setup.js +410 -0
- package/scripts/shots.mjs +272 -0
- package/scripts/smoke.sh +327 -0
- package/scripts/ui.js +174 -0
- package/server/auth/sessions.js +221 -0
- package/server/auth/users.js +243 -0
- package/server/chain/blockfile.js +234 -0
- package/server/chain/index/build.js +193 -0
- package/server/chain/index/heights.js +36 -0
- package/server/chain/index/live.js +276 -0
- package/server/chain/index/rows.js +145 -0
- package/server/chain/index/store.js +154 -0
- package/server/chain/index/worker.js +109 -0
- package/server/chain/tx.js +310 -0
- package/server/collect/gbt.js +229 -0
- package/server/collect/logparse.js +765 -0
- package/server/collect/logtail.js +189 -0
- package/server/collect/markets.js +333 -0
- package/server/collect/mining.js +333 -0
- package/server/collect/monitor.js +2516 -0
- package/server/collect/nextblock.js +275 -0
- package/server/collect/sync.js +386 -0
- package/server/config.js +620 -0
- package/server/http/api.js +1275 -0
- package/server/http/explorer.js +418 -0
- package/server/http/server.js +412 -0
- package/server/http/sse.js +176 -0
- package/server/http/static.js +212 -0
- package/server/main.js +628 -0
- package/server/netinfo.js +253 -0
- package/server/rpc/allowlist.js +130 -0
- package/server/rpc/client.js +414 -0
- package/server/store/audit.js +148 -0
- package/server/store/history.js +220 -0
- package/server/store/ledger.js +290 -0
- package/server/store/ring.js +173 -0
- package/server/util/fmt.js +29 -0
- package/systemd/blockyard.service +100 -0
package/scripts/ui.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// THE INSTALLER'S TERMINAL DRESS (scripts/setup.js, scripts/check.js): colour that steps aside when
|
|
2
|
+
// there is no terminal or NO_COLOR is set, a banner, numbered steps, marks for check results, a
|
|
3
|
+
// spinner, a progress bar that redraws in place, and a box for the summary. Pure formatting,
|
|
4
|
+
// exported so the pieces with arithmetic in them (the bar, the ETA) are tested without a terminal.
|
|
5
|
+
import { stdout } from 'node:process';
|
|
6
|
+
|
|
7
|
+
export const COLOUR = !!((stdout.isTTY || process.env.FORCE_COLOR) && !process.env.NO_COLOR && process.env.TERM !== 'dumb');
|
|
8
|
+
const wrap = (open, close) => (s) => (COLOUR ? `\x1b[${open}m${s}\x1b[${close}m` : String(s));
|
|
9
|
+
export const c = {
|
|
10
|
+
bold: wrap('1', '22'), dim: wrap('2', '22'), italic: wrap('3', '23'),
|
|
11
|
+
accent: wrap('38;5;214', '39'), ok: wrap('38;5;78', '39'), warn: wrap('38;5;221', '39'), bad: wrap('38;5;203', '39'),
|
|
12
|
+
cyan: wrap('38;5;80', '39'), white: wrap('97', '39'),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const MARK = {
|
|
16
|
+
ok: () => c.ok('✓'), warn: () => c.warn('!'), fail: () => c.bad('✗'), info: () => c.dim('·'),
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// THE MONOGRAM, IN PIXELS (operator, 2026-09-14: "I want the installer to have amazing ANSI Art here
|
|
20
|
+
// for the BY logo"). The same tile the favicon is: a rounded orange tile shaded light to dark
|
|
21
|
+
// across the diagonal, a dark B and Y with uprights two cells wide (a cell is twice as tall as it is
|
|
22
|
+
// wide, so a two-cell upright and a one-row crossbar are the same thickness on screen -- operator:
|
|
23
|
+
// "The black letters need to be thicker"), and three courses of blocks along the foot. One character
|
|
24
|
+
// cell per pixel, painted as a background colour, from the 256-colour table -- solid in every
|
|
25
|
+
// terminal font; without colour it falls back to three lines of box drawing.
|
|
26
|
+
const ART = [
|
|
27
|
+
' ...................... ',
|
|
28
|
+
' ........................ ',
|
|
29
|
+
'...#######...##......##...',
|
|
30
|
+
'...##....##..##......##...',
|
|
31
|
+
'...##....##...##....##....',
|
|
32
|
+
'...#######.....##..##.....',
|
|
33
|
+
'...##....##.....####......',
|
|
34
|
+
'...##....##......##.......',
|
|
35
|
+
'...##....##......##.......',
|
|
36
|
+
'...#######.......##.......',
|
|
37
|
+
' .ooooooo.oooooo.ooooooo. ',
|
|
38
|
+
' ...................... ',
|
|
39
|
+
];
|
|
40
|
+
const TILE = [223, 222, 215, 214, 208, 172, 166]; // light to dark, the favicon's gradient in 256 colours
|
|
41
|
+
const INK = 233;
|
|
42
|
+
function pixel(ch, x, y) {
|
|
43
|
+
if (ch === ' ') return null;
|
|
44
|
+
const t = (x / (ART[0].length - 1) + y / (ART.length - 1)) / 2;
|
|
45
|
+
const tone = TILE[Math.min(TILE.length - 1, Math.floor(t * TILE.length))];
|
|
46
|
+
if (ch === '#') return INK;
|
|
47
|
+
if (ch === 'o') return TILE[Math.min(TILE.length - 1, Math.floor(t * TILE.length) + 1)];
|
|
48
|
+
return tone;
|
|
49
|
+
}
|
|
50
|
+
export function monogram() {
|
|
51
|
+
if (!COLOUR) return [`${c.accent('▗▄▖')}`, `${c.accent('▐BY')}`, `${c.accent('▝▀▘')}`];
|
|
52
|
+
// ONE CELL PER PIXEL, painted as a background colour under a space. The first cut used the
|
|
53
|
+
// half-block (two pixels per row) and the Mac's Terminal drew every seam between them
|
|
54
|
+
// (operator: "What is this garbage?!"): a font decides how a half-block glyph sits in its cell,
|
|
55
|
+
// and a background colour fills the cell whatever the font.
|
|
56
|
+
return ART.map((row, y) => {
|
|
57
|
+
let line = '', open = null;
|
|
58
|
+
for (let x = 0; x < row.length; x++) {
|
|
59
|
+
const tone = pixel(row[x], x, y);
|
|
60
|
+
if (tone !== open) { line += open == null ? '' : '\x1b[49m'; line += tone == null ? '' : `\x1b[48;5;${tone}m`; open = tone; }
|
|
61
|
+
line += ' ';
|
|
62
|
+
}
|
|
63
|
+
return line + (open == null ? '' : '\x1b[49m');
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The banner: the monogram beside the name, the version and what this is; below it when narrow. */
|
|
68
|
+
export function banner(version, what = 'setup', width = cols()) {
|
|
69
|
+
const art = monogram();
|
|
70
|
+
const head = `${c.bold('Block')}${c.accent(c.bold('Yard'))} ${c.dim(version)} ${c.dim('·')} ${c.bold(what)}`;
|
|
71
|
+
// every line fits beside the tile in 80 columns (operator: "It needs to fit in 80 character space. Standard CRT")
|
|
72
|
+
const about = ['Live monitor, block explorer, markets and 3D', 'block-space viewer for Bitcoin Core.', '', 'Zero dependencies, self-hosted, read-only.', 'Apache-2.0.'].map((l) => c.dim(l));
|
|
73
|
+
if (!COLOUR) return `\n${art.map((r, i) => `${r} ${i === 0 ? head : i === 1 ? about[0] : i === 2 ? about[1] : ''}`).join('\n')}\n`;
|
|
74
|
+
const artW = strip(art[0]).length;
|
|
75
|
+
const beside = width >= artW + 3 + 49 + 2; // 26 + 3 + 49 + 2 = 80
|
|
76
|
+
if (!beside) return '\n' + art.map((r) => ` ${r}`).join('\n') + `\n\n ${head}\n ${about.join('\n ')}\n`;
|
|
77
|
+
const text = ['', '', head, ...about];
|
|
78
|
+
return '\n' + art.map((row, i) => ` ${row} ${text[i] ?? ''}`).join('\n') + '\n';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A numbered step heading with a rule out to the right. */
|
|
82
|
+
export function step(n, total, title, width = cols()) {
|
|
83
|
+
const head = ` ${c.accent(c.bold(`${n}`))}${c.dim(`/${total}`)} ${c.bold(title)} `;
|
|
84
|
+
const plain = ` ${n}/${total} ${title} `;
|
|
85
|
+
return `\n${head}${c.dim('─'.repeat(Math.max(4, width - plain.length - 2)))}\n`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** One check result, as a line. */
|
|
89
|
+
export function checkLine(status, name, detail, width = cols()) {
|
|
90
|
+
const mark = (MARK[status] ?? MARK.info)();
|
|
91
|
+
const label = status === 'fail' ? c.bad(name) : status === 'warn' ? c.warn(name) : status === 'info' ? c.dim(name) : name;
|
|
92
|
+
const room = Math.max(20, width - 26);
|
|
93
|
+
return ` ${mark} ${label.padEnd(name.length > 18 ? 0 : 18 + (label.length - name.length))} ${wrapText(detail, room, 25)}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Wrap a detail string so continuation lines sit under its first character. */
|
|
97
|
+
export function wrapText(text, width, indent) {
|
|
98
|
+
const words = String(text).split(' ');
|
|
99
|
+
const lines = [];
|
|
100
|
+
let line = '';
|
|
101
|
+
// widths are measured on what is seen, not on the colour codes around it
|
|
102
|
+
for (const w of words) {
|
|
103
|
+
if (line && strip(line).length + 1 + strip(w).length > width) { lines.push(line); line = w; } else line = line ? `${line} ${w}` : w;
|
|
104
|
+
}
|
|
105
|
+
if (line) lines.push(line);
|
|
106
|
+
return lines.join('\n' + ' '.repeat(indent));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Text in a rounded box. */
|
|
110
|
+
export function box(lines, { title = null, pad = 1, width = null, max = cols() - 6 } = {}) {
|
|
111
|
+
// NOTHING WIDER THAN THE TERMINAL (operator, 2026-09-14, of a box whose one long line broke its
|
|
112
|
+
// frame on an 80-column Mac): a line past the room is cut with an ellipsis -- a body line keeps
|
|
113
|
+
// its start, a title (a path) keeps its end
|
|
114
|
+
const room = Math.max(20, max - pad * 2);
|
|
115
|
+
const cut = (l, keepEnd) => { const r = strip(l); if (r.length <= room) return l; return keepEnd ? `…${r.slice(r.length - room + 1)}` : `${r.slice(0, room - 1)}…`; };
|
|
116
|
+
lines = lines.map((l) => cut(l, false));
|
|
117
|
+
if (title != null) title = cut(title, true);
|
|
118
|
+
const raw = lines.map((l) => strip(l));
|
|
119
|
+
const inner = width ?? Math.max(...raw.map((l) => l.length), title ? strip(title).length + 2 : 0) + pad * 2;
|
|
120
|
+
const top = title ? `╭─ ${title} ${'─'.repeat(Math.max(0, inner - strip(title).length - 3))}╮` : `╭${'─'.repeat(inner)}╮`;
|
|
121
|
+
const body = lines.map((l, i) => `│${' '.repeat(pad)}${l}${' '.repeat(Math.max(0, inner - pad - raw[i].length))}│`);
|
|
122
|
+
return [c.dim(top[0]) + top.slice(1, -1) + c.dim(top.slice(-1)), ...body.map((b) => c.dim('│') + b.slice(1, -1) + c.dim('│')), c.dim(`╰${'─'.repeat(inner)}╯`)].join('\n');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function strip(s) { return String(s).replace(/\x1b\[[0-9;]*m/g, ''); }
|
|
126
|
+
export function cols() { return Math.min(100, Math.max(60, stdout.columns ?? 80)); }
|
|
127
|
+
|
|
128
|
+
/** A spinner on a TTY; a single line otherwise. stop() erases it. */
|
|
129
|
+
export function spinner(text) {
|
|
130
|
+
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
131
|
+
let i = 0, timer = null;
|
|
132
|
+
if (stdout.isTTY) {
|
|
133
|
+
const draw = () => stdout.write(`\r ${c.accent(frames[i++ % frames.length])} ${text}`);
|
|
134
|
+
draw(); timer = setInterval(draw, 80);
|
|
135
|
+
} else stdout.write(` … ${text}\n`);
|
|
136
|
+
return { stop() { if (timer) { clearInterval(timer); stdout.write(`\r${' '.repeat(text.length + 8)}\r`); } } };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const HMS = (s) => (s < 90 ? `${Math.round(s)} s` : s < 5400 ? `${Math.round(s / 60)} min` : `${(s / 3600).toFixed(1)} h`);
|
|
140
|
+
const G = (n) => (n >= 1e9 ? `${(n / 1e9).toFixed(2)} B` : n >= 1e6 ? `${(n / 1e6).toFixed(1)} M` : n >= 1e3 ? `${(n / 1e3).toFixed(0)} k` : String(n));
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* One line of a progress bar: `phase ████░░ done/total · rows · rate · ETA`. `elapsed` in seconds
|
|
144
|
+
* since the phase began; the ETA assumes the rate so far holds. Pure, so it is tested.
|
|
145
|
+
*/
|
|
146
|
+
export function progressLine({ phase, done, total, rows = null, elapsed = 0 }, width = cols()) {
|
|
147
|
+
const frac = total > 0 ? Math.min(1, done / total) : 0;
|
|
148
|
+
const barW = Math.max(10, Math.min(30, width - 62));
|
|
149
|
+
const filled = Math.round(frac * barW);
|
|
150
|
+
const bar = c.accent('█'.repeat(filled)) + c.dim('░'.repeat(barW - filled));
|
|
151
|
+
const rate = elapsed > 0 && done > 0 ? done / elapsed : 0;
|
|
152
|
+
const eta = rate > 0 && total > done ? HMS((total - done) / rate) : null;
|
|
153
|
+
const bits = [`${String(done).padStart(String(total).length)}/${total}`, `${Math.round(frac * 100)}%`];
|
|
154
|
+
if (rows != null) bits.push(`${G(rows)} rows`);
|
|
155
|
+
if (eta) bits.push(`about ${eta} left`);
|
|
156
|
+
else if (frac >= 1) bits.push(`done in ${HMS(elapsed)}`);
|
|
157
|
+
return ` ${c.bold(phase.padEnd(7))} ${bar} ${c.dim(bits.join(' · '))}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Draws progress lines in place on a TTY; on a pipe, one line each time the percentage moves. */
|
|
161
|
+
export function progress() {
|
|
162
|
+
let lastPct = -1, lastPhase = null;
|
|
163
|
+
return {
|
|
164
|
+
update(p) {
|
|
165
|
+
const line = progressLine(p);
|
|
166
|
+
if (stdout.isTTY) { stdout.write(`\r\x1b[2K${line}`); return; }
|
|
167
|
+
const pct = p.total > 0 ? Math.floor((p.done / p.total) * 20) : 0;
|
|
168
|
+
if (p.phase !== lastPhase || pct !== lastPct) { lastPhase = p.phase; lastPct = pct; stdout.write(`${strip(line)}\n`); }
|
|
169
|
+
},
|
|
170
|
+
done(line) { if (stdout.isTTY) stdout.write(`\r\x1b[2K`); if (line) stdout.write(`${line}\n`); },
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export const fmt = { hms: HMS, big: G };
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Sessions, CSRF and rate limiting.
|
|
2
|
+
import fsp from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
function hashToken(token) {
|
|
7
|
+
return crypto.createHash('sha256').update(String(token)).digest('hex');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class SessionStore {
|
|
11
|
+
constructor(file, cfg) {
|
|
12
|
+
this.file = file;
|
|
13
|
+
this.cfg = cfg;
|
|
14
|
+
this.sessions = new Map(); // tokenHash -> session
|
|
15
|
+
this.loaded = false;
|
|
16
|
+
this.dirty = false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async load() {
|
|
20
|
+
try {
|
|
21
|
+
const data = JSON.parse(await fsp.readFile(this.file, 'utf8'));
|
|
22
|
+
for (const s of data.sessions ?? []) this.sessions.set(s.tokenHash, s);
|
|
23
|
+
this.loaded = true;
|
|
24
|
+
this.sweep();
|
|
25
|
+
return { loaded: true, count: this.sessions.size };
|
|
26
|
+
} catch (err) {
|
|
27
|
+
if (err.code !== 'ENOENT') throw new Error(`sessions file ${this.file} unreadable (${err.message})`);
|
|
28
|
+
return { loaded: false, count: 0 };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async save() {
|
|
33
|
+
if (!this.dirty) return;
|
|
34
|
+
await fsp.mkdir(path.dirname(this.file), { recursive: true });
|
|
35
|
+
const tmp = `${this.file}.tmp`;
|
|
36
|
+
const fh = await fsp.open(tmp, 'w', 0o600);
|
|
37
|
+
await fh.writeFile(JSON.stringify({ version: 1, savedAt: Date.now(), sessions: [...this.sessions.values()] }, null, 2));
|
|
38
|
+
await fh.sync();
|
|
39
|
+
await fh.close();
|
|
40
|
+
await fsp.rename(tmp, this.file);
|
|
41
|
+
this.dirty = false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
create(user, { ip = null, userAgent = null } = {}) {
|
|
45
|
+
const token = crypto.randomBytes(32).toString('base64url');
|
|
46
|
+
const csrf = crypto.randomBytes(24).toString('base64url');
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
const session = {
|
|
49
|
+
tokenHash: hashToken(token),
|
|
50
|
+
userId: user.id,
|
|
51
|
+
username: user.username,
|
|
52
|
+
role: user.role,
|
|
53
|
+
csrf,
|
|
54
|
+
createdAt: now,
|
|
55
|
+
lastSeenAt: now,
|
|
56
|
+
ip,
|
|
57
|
+
// Stored truncated and never echoed back: useful for "sign out everywhere
|
|
58
|
+
// else", and it must not turn into a place the app leaks device detail.
|
|
59
|
+
userAgent: userAgent ? String(userAgent).slice(0, 120) : null,
|
|
60
|
+
};
|
|
61
|
+
this.sessions.set(session.tokenHash, session);
|
|
62
|
+
this.dirty = true;
|
|
63
|
+
return { token, session };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
get(token) {
|
|
67
|
+
if (!token) return null;
|
|
68
|
+
const s = this.sessions.get(hashToken(token));
|
|
69
|
+
if (!s) return null;
|
|
70
|
+
const now = Date.now();
|
|
71
|
+
if (now - s.createdAt > this.cfg.sessionTtlMs) { this.sessions.delete(s.tokenHash); this.dirty = true; return null; }
|
|
72
|
+
if (now - s.lastSeenAt > this.cfg.idleTtlMs) { this.sessions.delete(s.tokenHash); this.dirty = true; return null; }
|
|
73
|
+
// Touch at most every 30s: writing a timestamp on every request would make
|
|
74
|
+
// every SSE heartbeat a state mutation.
|
|
75
|
+
if (now - s.lastSeenAt > 30_000) { s.lastSeenAt = now; this.dirty = true; }
|
|
76
|
+
return s;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
destroy(token) {
|
|
80
|
+
const hit = this.sessions.delete(hashToken(token));
|
|
81
|
+
if (hit) this.dirty = true;
|
|
82
|
+
return hit;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
destroyForUser(userId) {
|
|
86
|
+
let n = 0;
|
|
87
|
+
for (const [k, s] of this.sessions) if (s.userId === userId) { this.sessions.delete(k); n += 1; }
|
|
88
|
+
if (n) this.dirty = true;
|
|
89
|
+
return n;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
sweep() {
|
|
93
|
+
const now = Date.now();
|
|
94
|
+
let n = 0;
|
|
95
|
+
for (const [k, s] of this.sessions) {
|
|
96
|
+
if (now - s.createdAt > this.cfg.sessionTtlMs || now - s.lastSeenAt > this.cfg.idleTtlMs) { this.sessions.delete(k); n += 1; }
|
|
97
|
+
}
|
|
98
|
+
if (n) this.dirty = true;
|
|
99
|
+
return n;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
active() {
|
|
103
|
+
const byUser = new Map();
|
|
104
|
+
for (const s of this.sessions.values()) byUser.set(s.userId, (byUser.get(s.userId) ?? 0) + 1);
|
|
105
|
+
return { sessions: this.sessions.size, users: byUser.size };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
listFor(userId) {
|
|
109
|
+
return [...this.sessions.values()]
|
|
110
|
+
.filter((s) => s.userId === userId)
|
|
111
|
+
.map((s) => ({ createdAt: s.createdAt, lastSeenAt: s.lastSeenAt, ip: s.ip, userAgent: s.userAgent, current: false }));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Token bucket: a burst is fine, a sustained flood is not.
|
|
116
|
+
export class RateLimiter {
|
|
117
|
+
constructor({ capacity = 30, perSec = 10 } = {}) {
|
|
118
|
+
this.capacity = capacity;
|
|
119
|
+
this.perSec = perSec;
|
|
120
|
+
this.buckets = new Map();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
check(key, cost = 1) {
|
|
124
|
+
const now = Date.now();
|
|
125
|
+
let b = this.buckets.get(key);
|
|
126
|
+
if (!b) { b = { tokens: this.capacity, at: now }; this.buckets.set(key, b); }
|
|
127
|
+
b.tokens = Math.min(this.capacity, b.tokens + ((now - b.at) / 1000) * this.perSec);
|
|
128
|
+
b.at = now;
|
|
129
|
+
if (b.tokens < cost) {
|
|
130
|
+
const need = cost - b.tokens;
|
|
131
|
+
return { ok: false, retryAfterMs: Math.ceil((need / this.perSec) * 1000), remaining: 0 };
|
|
132
|
+
}
|
|
133
|
+
b.tokens -= cost;
|
|
134
|
+
// Keep the map from growing without bound on a chatty LAN.
|
|
135
|
+
if (this.buckets.size > 5000) {
|
|
136
|
+
const cutoff = now - 600_000;
|
|
137
|
+
for (const [k, v] of this.buckets) if (v.at < cutoff) this.buckets.delete(k);
|
|
138
|
+
}
|
|
139
|
+
return { ok: true, remaining: Math.floor(b.tokens) };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Failed-login tracking, keyed on BOTH username and address: a spray across many
|
|
144
|
+
// usernames from one host and a grind on one username from many hosts are
|
|
145
|
+
// different attacks and both need to be visible.
|
|
146
|
+
export class LoginGuard {
|
|
147
|
+
constructor({ maxAttempts = 8, windowMs = 300000, lockoutMs = 600000 } = {}) {
|
|
148
|
+
this.maxAttempts = maxAttempts;
|
|
149
|
+
this.windowMs = windowMs;
|
|
150
|
+
this.lockoutMs = lockoutMs;
|
|
151
|
+
this.attempts = new Map();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
_entry(key) {
|
|
155
|
+
let e = this.attempts.get(key);
|
|
156
|
+
if (!e) { e = { hits: [], lockedUntil: 0 }; this.attempts.set(key, e); }
|
|
157
|
+
const cutoff = Date.now() - this.windowMs;
|
|
158
|
+
e.hits = e.hits.filter((t) => t > cutoff);
|
|
159
|
+
return e;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
status(username, ip) {
|
|
163
|
+
const a = this._entry(`u:${username}`);
|
|
164
|
+
const b = this._entry(`i:${ip}`);
|
|
165
|
+
const locked = Math.max(a.lockedUntil, b.lockedUntil);
|
|
166
|
+
if (locked > Date.now()) return { blocked: true, retryAfterMs: locked - Date.now() };
|
|
167
|
+
const worst = Math.max(a.hits.length, b.hits.length);
|
|
168
|
+
return { blocked: false, attempts: worst, remaining: Math.max(0, this.maxAttempts - worst) };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
noteFailure(username, ip) {
|
|
172
|
+
const out = [];
|
|
173
|
+
for (const key of [`u:${username}`, `i:${ip}`]) {
|
|
174
|
+
const e = this._entry(key);
|
|
175
|
+
e.hits.push(Date.now());
|
|
176
|
+
if (e.hits.length >= this.maxAttempts) {
|
|
177
|
+
e.lockedUntil = Date.now() + this.lockoutMs;
|
|
178
|
+
e.hits = [];
|
|
179
|
+
out.push(key);
|
|
180
|
+
}
|
|
181
|
+
this.attempts.set(key, e);
|
|
182
|
+
}
|
|
183
|
+
return { locked: out };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
noteSuccess(username, ip) {
|
|
187
|
+
this.attempts.delete(`u:${username}`);
|
|
188
|
+
this.attempts.delete(`i:${ip}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function parseCookies(header = '') {
|
|
193
|
+
const out = {};
|
|
194
|
+
for (const part of String(header).split(';')) {
|
|
195
|
+
const i = part.indexOf('=');
|
|
196
|
+
if (i < 0) continue;
|
|
197
|
+
const k = part.slice(0, i).trim();
|
|
198
|
+
if (!k) continue;
|
|
199
|
+
try { out[k] = decodeURIComponent(part.slice(i + 1).trim()); } catch { out[k] = part.slice(i + 1).trim(); }
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function serializeCookie(name, value, { maxAgeMs, httpOnly = true, secure = false, sameSite = 'Strict', path: p = '/' } = {}) {
|
|
205
|
+
const bits = [`${name}=${encodeURIComponent(value)}`, `Path=${p}`, `SameSite=${sameSite}`];
|
|
206
|
+
if (httpOnly) bits.push('HttpOnly');
|
|
207
|
+
if (secure) bits.push('Secure');
|
|
208
|
+
if (maxAgeMs != null) bits.push(`Max-Age=${Math.floor(maxAgeMs / 1000)}`);
|
|
209
|
+
return bits.join('; ');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// CSRF: the session cookie is HttpOnly + SameSite=Strict already; this adds the
|
|
213
|
+
// double-submit check on top so a cross-origin POST cannot slip past a browser
|
|
214
|
+
// that relaxed SameSite for some reason.
|
|
215
|
+
export function csrfOk(session, headerValue) {
|
|
216
|
+
if (!session?.csrf) return false;
|
|
217
|
+
if (!headerValue) return false;
|
|
218
|
+
const a = Buffer.from(String(session.csrf));
|
|
219
|
+
const b = Buffer.from(String(headerValue));
|
|
220
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
221
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// Users: scrypt-hashed passwords in a JSON store, atomic writes.
|
|
2
|
+
//
|
|
3
|
+
// Threat model note: this is a multi-user webserver on a LAN that can read the
|
|
4
|
+
// secrets of a bitcoin node (the RPC cookie is readable by this process). So the
|
|
5
|
+
// credential store must not be the soft spot. scrypt at the default parameters costs
|
|
6
|
+
// 20 ms and 16.0 MB per guess, measured on this box on 2026-09-09 (128·r·N; the
|
|
7
|
+
// "~50 ms" this comment previously claimed was an estimate and is corrected here).
|
|
8
|
+
// That is what makes an offline crack of a leaked users.json expensive rather than
|
|
9
|
+
// trivial -- and also why an unthrottled login endpoint is a CPU denial of service
|
|
10
|
+
// (see app.loginLimiter in server/main.js and MEASUREMENTS §22). Never logged, never
|
|
11
|
+
// returned by any API, never sent to the browser.
|
|
12
|
+
import fsp from 'node:fs/promises';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import crypto from 'node:crypto';
|
|
16
|
+
|
|
17
|
+
const USERNAME_RE = /^[a-z0-9][a-z0-9._-]{1,30}$/;
|
|
18
|
+
export const ROLES = ['viewer', 'operator', 'admin'];
|
|
19
|
+
|
|
20
|
+
export class UserStore {
|
|
21
|
+
constructor(file, cfg) {
|
|
22
|
+
this.file = file;
|
|
23
|
+
this.cfg = cfg;
|
|
24
|
+
this.users = [];
|
|
25
|
+
this.loaded = false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async load() {
|
|
29
|
+
try {
|
|
30
|
+
const raw = await fsp.readFile(this.file, 'utf8');
|
|
31
|
+
const data = JSON.parse(raw);
|
|
32
|
+
this.users = Array.isArray(data.users) ? data.users : [];
|
|
33
|
+
this.loaded = true;
|
|
34
|
+
return { loaded: true, count: this.users.length };
|
|
35
|
+
} catch (err) {
|
|
36
|
+
if (err.code === 'ENOENT') return { loaded: false, count: 0 };
|
|
37
|
+
// A corrupt user file must not silently mean "no users", because the
|
|
38
|
+
// bootstrap path would then create a second admin and a later save would
|
|
39
|
+
// erase whoever was there. Refuse loudly instead.
|
|
40
|
+
throw new Error(`users store at ${this.file} is unreadable (${err.message}); refusing to start rather than overwrite it`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async save() {
|
|
45
|
+
await fsp.mkdir(path.dirname(this.file), { recursive: true });
|
|
46
|
+
const tmp = `${this.file}.tmp`;
|
|
47
|
+
const fh = await fsp.open(tmp, 'w', 0o600);
|
|
48
|
+
await fh.writeFile(JSON.stringify({ version: 1, updatedAt: Date.now(), users: this.users }, null, 2));
|
|
49
|
+
await fh.sync();
|
|
50
|
+
await fh.close();
|
|
51
|
+
await fsp.rename(tmp, this.file);
|
|
52
|
+
try { await fsp.chmod(this.file, 0o600); } catch { /* filesystem may not support it */ }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get count() { return this.users.length; }
|
|
56
|
+
list() {
|
|
57
|
+
return this.users.map((u) => ({
|
|
58
|
+
id: u.id, username: u.username, role: u.role, createdAt: u.createdAt,
|
|
59
|
+
updatedAt: u.updatedAt, lastLoginAt: u.lastLoginAt ?? null, disabled: !!u.disabled,
|
|
60
|
+
scheme: u.params?.scheme ?? 'scrypt',
|
|
61
|
+
// Visible so raising auth.scrypt is a change with an observable effect: the
|
|
62
|
+
// admin page can say "2 of 5 accounts still use N=16384" instead of the
|
|
63
|
+
// operator wondering whether the new number took.
|
|
64
|
+
kdf: u.params ? { N: u.params.N, r: u.params.r, p: u.params.p, keylen: u.params.keylen } : null,
|
|
65
|
+
kdfNeedsUpgrade: !!u.params && needsRehash(u, this.cfg?.scrypt),
|
|
66
|
+
}));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
find(username) {
|
|
70
|
+
const want = String(username ?? '').toLowerCase();
|
|
71
|
+
return this.users.find((u) => u.username === want) ?? null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
byId(id) { return this.users.find((u) => u.id === id) ?? null; }
|
|
75
|
+
|
|
76
|
+
async createUser(username, password, { role = 'viewer' } = {}) {
|
|
77
|
+
const uname = String(username ?? '').toLowerCase().trim();
|
|
78
|
+
if (!USERNAME_RE.test(uname)) throw new Error('username must be 2-31 chars: lowercase letters, digits, dot, dash, underscore');
|
|
79
|
+
if (this.find(uname)) throw new Error(`user "${uname}" already exists`);
|
|
80
|
+
if (!ROLES.includes(role)) throw new Error(`role must be one of ${ROLES.join(', ')}`);
|
|
81
|
+
this.checkPasswordStrength(password, uname);
|
|
82
|
+
const { hash, params } = await hashPassword(password, this.cfg.scrypt);
|
|
83
|
+
const user = {
|
|
84
|
+
id: crypto.randomBytes(8).toString('hex'),
|
|
85
|
+
username: uname,
|
|
86
|
+
salt: params.salt.toString('hex'),
|
|
87
|
+
hash: hash.toString('hex'),
|
|
88
|
+
params: { N: params.N, r: params.r, p: params.p, keylen: params.keylen, scheme: 'scrypt' },
|
|
89
|
+
role,
|
|
90
|
+
createdAt: Date.now(),
|
|
91
|
+
updatedAt: Date.now(),
|
|
92
|
+
lastLoginAt: null,
|
|
93
|
+
disabled: false,
|
|
94
|
+
};
|
|
95
|
+
this.users.push(user);
|
|
96
|
+
await this.save();
|
|
97
|
+
return { id: user.id, username: user.username, role: user.role };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
checkPasswordStrength(password, uname) {
|
|
101
|
+
const pw = String(password ?? '');
|
|
102
|
+
const min = this.cfg.minPasswordChars;
|
|
103
|
+
if (pw.length < min) throw new Error(`password must be at least ${min} characters`);
|
|
104
|
+
const lower = pw.toLowerCase();
|
|
105
|
+
if (uname && lower.includes(uname)) throw new Error('password must not contain the username');
|
|
106
|
+
// No composition rules (they push people to "Password123!"), but a few
|
|
107
|
+
// shapes that appear in every breach corpus are simply refused.
|
|
108
|
+
const weak = /^(password|letmein|admin|welcome|qwerty|123456|changeme|bitcoin|btc)/i.test(pw);
|
|
109
|
+
if (weak) throw new Error('password starts from a shape that appears in every breach corpus; pick something else');
|
|
110
|
+
if (/^(.)\1+$/.test(pw)) throw new Error('password is a single repeated character');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Verify a password, and quietly bring the stored hash up to date.
|
|
115
|
+
*
|
|
116
|
+
* The upgrade path is the reason the parameters are stored per user rather than
|
|
117
|
+
* globally: a KDF cost is not a constant, it is a guess about next year's
|
|
118
|
+
* hardware, and an installation that never rehashes keeps verifying against
|
|
119
|
+
* 2026's choice forever. Doing it here — at the one moment the plaintext is in
|
|
120
|
+
* hand — means raising `auth.scrypt` in the config actually raises it for every
|
|
121
|
+
* account, one login at a time, with no forced password resets.
|
|
122
|
+
*
|
|
123
|
+
* An unknown `scheme` is refused rather than assumed to be scrypt: if this store
|
|
124
|
+
* ever gains a second scheme, silently interpreting it wrong would mean every
|
|
125
|
+
* password under it stops working (worse: starts working under weaker rules).
|
|
126
|
+
*/
|
|
127
|
+
async verify(username, password) {
|
|
128
|
+
const user = this.find(username);
|
|
129
|
+
// Always do the KDF work, even for an unknown username, so response time
|
|
130
|
+
// does not tell an attacker whether the account exists.
|
|
131
|
+
const target = user ?? { salt: crypto.randomBytes(16).toString('hex'), hash: Buffer.alloc(32), params: { N: this.cfg.scrypt.N, r: this.cfg.scrypt.r, p: this.cfg.scrypt.p, keylen: this.cfg.scrypt.keylen } };
|
|
132
|
+
const params = { N: target.params?.N ?? 16384, r: target.params?.r ?? 8, p: target.params?.p ?? 1, keylen: target.params?.keylen ?? 32 };
|
|
133
|
+
const scheme = target.params?.scheme ?? 'scrypt';
|
|
134
|
+
if (scheme !== 'scrypt') {
|
|
135
|
+
if (!user) return { ok: false, user: null, reason: 'no such user' };
|
|
136
|
+
return { ok: false, user, reason: `unsupported hash scheme "${scheme}"; reset this password` };
|
|
137
|
+
}
|
|
138
|
+
const derived = await scrypt(String(password ?? ''), Buffer.from(target.salt, 'hex'), params);
|
|
139
|
+
const known = Buffer.from(target.hash, 'hex');
|
|
140
|
+
const ok = known.length === derived.length && crypto.timingSafeEqual(known, derived);
|
|
141
|
+
if (!user) return { ok: false, user: null, reason: 'no such user' };
|
|
142
|
+
if (user.disabled) return { ok: false, user, reason: 'account disabled' };
|
|
143
|
+
if (!ok) return { ok: false, user, reason: 'bad password' };
|
|
144
|
+
user.lastLoginAt = Date.now();
|
|
145
|
+
let upgraded = null;
|
|
146
|
+
if (needsRehash(user, this.cfg.scrypt)) {
|
|
147
|
+
// Only on a verified password: rehashing a wrong password would store a hash
|
|
148
|
+
// of the wrong secret. Re-derived with the CURRENT configured cost, new salt.
|
|
149
|
+
const { hash, params: np } = await hashPassword(String(password ?? ''), this.cfg.scrypt);
|
|
150
|
+
upgraded = { from: { ...params }, to: { N: np.N, r: np.r, p: np.p, keylen: np.keylen } };
|
|
151
|
+
user.salt = np.salt.toString('hex');
|
|
152
|
+
user.hash = hash.toString('hex');
|
|
153
|
+
user.params = { N: np.N, r: np.r, p: np.p, keylen: np.keylen, scheme: 'scrypt' };
|
|
154
|
+
user.updatedAt = Date.now();
|
|
155
|
+
}
|
|
156
|
+
await this.save().catch(() => {});
|
|
157
|
+
return { ok: true, user, upgraded };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async setPassword(username, password) {
|
|
161
|
+
const user = this.find(username);
|
|
162
|
+
if (!user) throw new Error('no such user');
|
|
163
|
+
this.checkPasswordStrength(password, user.username);
|
|
164
|
+
const { hash, params } = await hashPassword(password, this.cfg.scrypt);
|
|
165
|
+
user.salt = params.salt.toString('hex');
|
|
166
|
+
user.hash = hash.toString('hex');
|
|
167
|
+
user.params = { N: params.N, r: params.r, p: params.p, keylen: params.keylen, scheme: 'scrypt' };
|
|
168
|
+
user.updatedAt = Date.now();
|
|
169
|
+
await this.save();
|
|
170
|
+
return { id: user.id, username: user.username };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async setRole(username, role) {
|
|
174
|
+
if (!ROLES.includes(role)) throw new Error(`role must be one of ${ROLES.join(', ')}`);
|
|
175
|
+
const user = this.find(username);
|
|
176
|
+
if (!user) throw new Error('no such user');
|
|
177
|
+
const admins = this.users.filter((u) => u.role === 'admin' && !u.disabled);
|
|
178
|
+
if (user.role === 'admin' && role !== 'admin' && admins.length <= 1) {
|
|
179
|
+
// Losing the last admin leaves nobody able to administer; refuse rather
|
|
180
|
+
// than talk the operator out of their own tool.
|
|
181
|
+
throw new Error('refusing to demote the last enabled admin');
|
|
182
|
+
}
|
|
183
|
+
user.role = role;
|
|
184
|
+
user.updatedAt = Date.now();
|
|
185
|
+
await this.save();
|
|
186
|
+
return { id: user.id, username: user.username, role };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async setDisabled(username, disabled) {
|
|
190
|
+
const user = this.find(username);
|
|
191
|
+
if (!user) throw new Error('no such user');
|
|
192
|
+
const admins = this.users.filter((u) => u.role === 'admin' && !u.disabled);
|
|
193
|
+
if (user.role === 'admin' && disabled && admins.length <= 1) throw new Error('refusing to disable the last enabled admin');
|
|
194
|
+
user.disabled = !!disabled;
|
|
195
|
+
user.updatedAt = Date.now();
|
|
196
|
+
await this.save();
|
|
197
|
+
return { id: user.id, username: user.username, disabled: user.disabled };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async deleteUser(username) {
|
|
201
|
+
const user = this.find(username);
|
|
202
|
+
if (!user) throw new Error('no such user');
|
|
203
|
+
const admins = this.users.filter((u) => u.role === 'admin' && !u.disabled);
|
|
204
|
+
if (user.role === 'admin' && admins.length <= 1) throw new Error('refusing to delete the last enabled admin');
|
|
205
|
+
this.users = this.users.filter((u) => u.id !== user.id);
|
|
206
|
+
await this.save();
|
|
207
|
+
return { deleted: user.username };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function scrypt(password, salt, { N, r, p, keylen }) {
|
|
212
|
+
return new Promise((resolve, reject) => {
|
|
213
|
+
crypto.scrypt(password, salt, keylen, { N, r, p, maxmem: Math.max(64 * 1024 * 1024, 128 * r * N + 1024 * 1024) }, (err, dk) => (err ? reject(err) : resolve(dk)));
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Would this hash be derived differently under the currently configured cost? */
|
|
218
|
+
export function needsRehash(user, cfgScrypt) {
|
|
219
|
+
if (!user?.params || !cfgScrypt) return false;
|
|
220
|
+
const p = user.params;
|
|
221
|
+
return p.N !== cfgScrypt.N || p.r !== cfgScrypt.r || p.p !== cfgScrypt.p || p.keylen !== cfgScrypt.keylen;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function hashPassword(password, base) {
|
|
225
|
+
const salt = crypto.randomBytes(16);
|
|
226
|
+
const params = { ...base, salt };
|
|
227
|
+
const hash = await scrypt(password, salt, base);
|
|
228
|
+
return { hash, params };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// One-time bootstrap credential. Printed once to stdout and never stored in
|
|
232
|
+
// plaintext anywhere, so there is nothing to go looking for afterwards.
|
|
233
|
+
export function randomPassword(len = 20) {
|
|
234
|
+
const alphabet = 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789!@#$%*-+=';
|
|
235
|
+
// crypto.randomInt, NOT randomBytes()%n. The alphabet is 66 long and 256 % 66 = 58, so the
|
|
236
|
+
// modulo form drew the first 58 characters slightly more often than the rest -- a small bias,
|
|
237
|
+
// in the one function whose entire job is to be an unguessable bootstrap credential. randomInt
|
|
238
|
+
// rejection-samples internally, so the distribution is flat. (Audit, 2026-09-13.)
|
|
239
|
+
let out = '';
|
|
240
|
+
for (let i = 0; i < len; i++) out += alphabet[crypto.randomInt(alphabet.length)];
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
|