Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA

This commit is contained in:
2026-09-04 14:55:59 +10:00
commit 3278890491
41 changed files with 8920 additions and 0 deletions
+147
View File
@@ -0,0 +1,147 @@
import crypto from 'node:crypto';
/* ------------------------------------------------------------ passwords */
// scrypt is built into Node, so there is no native module to compile in the image.
export function hashPassword(password) {
const salt = crypto.randomBytes(16);
const hash = crypto.scryptSync(String(password), salt, 64, { N: 16384, r: 8, p: 1 });
return `scrypt$${salt.toString('base64')}$${hash.toString('base64')}`;
}
export function verifyPassword(password, stored) {
try {
const [scheme, saltB64, hashB64] = String(stored).split('$');
if (scheme !== 'scrypt') return false;
const expected = Buffer.from(hashB64, 'base64');
const actual = crypto.scryptSync(String(password), Buffer.from(saltB64, 'base64'), expected.length, {
N: 16384,
r: 8,
p: 1,
});
return crypto.timingSafeEqual(expected, actual);
} catch {
return false;
}
}
export function passwordProblem(password) {
const value = String(password || '');
if (value.length < 12) return 'Use at least 12 characters.';
if (!/[a-z]/.test(value) || !/[A-Z]/.test(value)) return 'Mix upper and lower case.';
if (!/\d/.test(value)) return 'Include at least one number.';
return null;
}
export function randomPassword() {
// Readable enough to hand over verbally, still 60+ bits of entropy.
const words = crypto.randomBytes(9).toString('base64url').replace(/[-_]/g, '');
return `Vs${words}9`;
}
/* ----------------------------------------------------------- base32 */
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
export function base32Encode(buffer) {
let bits = 0;
let value = 0;
let output = '';
for (const byte of buffer) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
output += ALPHABET[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) output += ALPHABET[(value << (5 - bits)) & 31];
return output;
}
export function base32Decode(input) {
const clean = String(input).toUpperCase().replace(/[^A-Z2-7]/g, '');
let bits = 0;
let value = 0;
const bytes = [];
for (const char of clean) {
value = (value << 5) | ALPHABET.indexOf(char);
bits += 5;
if (bits >= 8) {
bytes.push((value >>> (bits - 8)) & 255);
bits -= 8;
}
}
return Buffer.from(bytes);
}
/* -------------------------------------------------------------- TOTP */
export function generateTotpSecret() {
return base32Encode(crypto.randomBytes(20));
}
function hotp(secretBuffer, counter) {
const buf = Buffer.alloc(8);
buf.writeBigUInt64BE(BigInt(counter));
const digest = crypto.createHmac('sha1', secretBuffer).update(buf).digest();
const offset = digest[digest.length - 1] & 0x0f;
const code =
((digest[offset] & 0x7f) << 24) |
((digest[offset + 1] & 0xff) << 16) |
((digest[offset + 2] & 0xff) << 8) |
(digest[offset + 3] & 0xff);
return String(code % 1_000_000).padStart(6, '0');
}
export function totpCode(secret, atMs = Date.now(), stepSeconds = 30) {
return hotp(base32Decode(secret), Math.floor(atMs / 1000 / stepSeconds));
}
/** Allows one step either side, which covers a phone clock that has drifted a little. */
export function verifyTotp(secret, token, window = 1) {
const candidate = String(token || '').replace(/\D/g, '');
if (candidate.length !== 6) return false;
const counter = Math.floor(Date.now() / 1000 / 30);
const buffer = base32Decode(secret);
for (let drift = -window; drift <= window; drift += 1) {
const expected = hotp(buffer, counter + drift);
if (crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(candidate))) return true;
}
return false;
}
export function otpauthUrl({ secret, email, issuer }) {
const label = encodeURIComponent(`${issuer}:${email}`);
const params = new URLSearchParams({ secret, issuer, algorithm: 'SHA1', digits: '6', period: '30' });
return `otpauth://totp/${label}?${params}`;
}
/* --------------------------------------------------- recovery codes */
export function generateRecoveryCodes(count = 8) {
return Array.from({ length: count }, () =>
crypto.randomBytes(5).toString('hex').replace(/(.{5})(.{5})/, '$1-$2')
);
}
const digest = (code) =>
crypto.createHash('sha256').update(String(code).toLowerCase().replace(/[^a-z0-9]/g, '')).digest('hex');
export function hashRecoveryCodes(codes) {
return JSON.stringify(codes.map(digest));
}
/** Returns the remaining codes if one matched, or null. Used codes are burnt. */
export function consumeRecoveryCode(storedJson, candidate) {
try {
const hashes = JSON.parse(storedJson || '[]');
const target = digest(candidate);
const index = hashes.indexOf(target);
if (index === -1) return null;
hashes.splice(index, 1);
return JSON.stringify(hashes);
} catch {
return null;
}
}