115 lines
4.1 KiB
JavaScript
115 lines
4.1 KiB
JavaScript
|
|
// TOTP (RFC 6238) implemented on node:crypto — no runtime dependency.
|
||
|
|
//
|
||
|
|
// Why hand-rolled: the algorithm is small and stable, and avoiding a dep keeps
|
||
|
|
// the auth core auditable. Verified against the RFC 6238 Appendix B test vectors
|
||
|
|
// in test/auth/totp.test.js.
|
||
|
|
//
|
||
|
|
// Defaults match every mainstream authenticator app (Google Authenticator,
|
||
|
|
// Authy, 1Password): SHA-1, 6 digits, 30-second step.
|
||
|
|
|
||
|
|
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
||
|
|
|
||
|
|
const DIGITS = 6;
|
||
|
|
const STEP_SECONDS = 30;
|
||
|
|
const RFC4648_B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||
|
|
|
||
|
|
// ── base32 (RFC 4648, no padding) ──────────────────────────────────────────
|
||
|
|
export function base32Encode(buf) {
|
||
|
|
let bits = 0, value = 0, out = '';
|
||
|
|
for (const byte of buf) {
|
||
|
|
value = (value << 8) | byte;
|
||
|
|
bits += 8;
|
||
|
|
while (bits >= 5) {
|
||
|
|
out += RFC4648_B32[(value >>> (bits - 5)) & 31];
|
||
|
|
bits -= 5;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (bits > 0) out += RFC4648_B32[(value << (5 - bits)) & 31];
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function base32Decode(str) {
|
||
|
|
const clean = str.replace(/=+$/,'').toUpperCase().replace(/\s+/g, '');
|
||
|
|
let bits = 0, value = 0;
|
||
|
|
const out = [];
|
||
|
|
for (const ch of clean) {
|
||
|
|
const idx = RFC4648_B32.indexOf(ch);
|
||
|
|
if (idx === -1) continue; // skip stray chars
|
||
|
|
value = (value << 5) | idx;
|
||
|
|
bits += 5;
|
||
|
|
if (bits >= 8) {
|
||
|
|
out.push((value >>> (bits - 8)) & 0xff);
|
||
|
|
bits -= 8;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return Buffer.from(out);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Generate a new base32 secret (20 random bytes = 160 bits, the RFC-recommended
|
||
|
|
// SHA-1 key length).
|
||
|
|
export function generateSecret() {
|
||
|
|
return base32Encode(randomBytes(20));
|
||
|
|
}
|
||
|
|
|
||
|
|
// HOTP for a specific counter (RFC 4226).
|
||
|
|
function hotp(secretBuf, counter) {
|
||
|
|
const buf = Buffer.alloc(8);
|
||
|
|
// 64-bit big-endian counter.
|
||
|
|
buf.writeUInt32BE(Math.floor(counter / 0x100000000), 0);
|
||
|
|
buf.writeUInt32BE(counter >>> 0, 4);
|
||
|
|
const hmac = createHmac('sha1', secretBuf).update(buf).digest();
|
||
|
|
const offset = hmac[hmac.length - 1] & 0x0f;
|
||
|
|
const code = ((hmac[offset] & 0x7f) << 24)
|
||
|
|
| ((hmac[offset + 1] & 0xff) << 16)
|
||
|
|
| ((hmac[offset + 2] & 0xff) << 8)
|
||
|
|
| (hmac[offset + 3] & 0xff);
|
||
|
|
return String(code % (10 ** DIGITS)).padStart(DIGITS, '0');
|
||
|
|
}
|
||
|
|
|
||
|
|
// The TOTP code for a given time (defaults to now).
|
||
|
|
export function generateToken(base32Secret, atMs = Date.now()) {
|
||
|
|
const counter = Math.floor(atMs / 1000 / STEP_SECONDS);
|
||
|
|
return hotp(base32Decode(base32Secret), counter);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Verify a user-supplied code, allowing ±`window` steps of clock drift
|
||
|
|
// (default ±1 = 90s total tolerance). Constant-time compare per candidate.
|
||
|
|
export function verifyToken(base32Secret, token, atMs = Date.now(), window = 1) {
|
||
|
|
if (!base32Secret || !token) return false;
|
||
|
|
const cleaned = String(token).replace(/\s+/g, '');
|
||
|
|
if (!/^\d{6}$/.test(cleaned)) return false;
|
||
|
|
const secretBuf = base32Decode(base32Secret);
|
||
|
|
const counter = Math.floor(atMs / 1000 / STEP_SECONDS);
|
||
|
|
const want = Buffer.from(cleaned);
|
||
|
|
for (let w = -window; w <= window; w++) {
|
||
|
|
const candidate = Buffer.from(hotp(secretBuf, counter + w));
|
||
|
|
if (candidate.length === want.length && timingSafeEqual(candidate, want)) return true;
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
// The otpauth:// URI an authenticator app scans. label/issuer show in the app.
|
||
|
|
export function otpauthURI(base32Secret, accountName, issuer = 'Dragonflight') {
|
||
|
|
const label = encodeURIComponent(`${issuer}:${accountName}`);
|
||
|
|
const params = new URLSearchParams({
|
||
|
|
secret: base32Secret,
|
||
|
|
issuer,
|
||
|
|
algorithm: 'SHA1',
|
||
|
|
digits: String(DIGITS),
|
||
|
|
period: String(STEP_SECONDS),
|
||
|
|
});
|
||
|
|
return `otpauth://totp/${label}?${params.toString()}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Generate N human-friendly one-time recovery codes (raw form). Caller hashes
|
||
|
|
// them before storage and shows the raw set to the user exactly once.
|
||
|
|
export function generateRecoveryCodes(n = 10) {
|
||
|
|
const codes = [];
|
||
|
|
for (let i = 0; i < n; i++) {
|
||
|
|
// 10 hex chars in two dash-separated groups, e.g. "a1b2c-3d4e5".
|
||
|
|
const hex = randomBytes(5).toString('hex');
|
||
|
|
codes.push(hex.slice(0, 5) + '-' + hex.slice(5));
|
||
|
|
}
|
||
|
|
return codes;
|
||
|
|
}
|