Public Access
148 lines
4.7 KiB
JavaScript
148 lines
4.7 KiB
JavaScript
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;
|
|
}
|
|
}
|