template-monorepo/test/k6/lib/totp.js

107 lines
4.0 KiB
JavaScript
Raw Permalink Normal View History

2026-05-26 06:05:33 +00:00
// TOTP (RFC 6238) generator for k6.
//
// Used by journeys/totp_full.js: parse the otpauth_url returned by
// /me/totp/enroll-start, then compute a 6-digit code for the current 30s window.
//
// k6 ships HMAC-SHA1 in k6/crypto so no xk6 extension is required.
import crypto from 'k6/crypto';
const BASE32_ALPHA = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
// base32Decode returns a Uint8Array for a (possibly padded) base32 string.
// Accepts mixed case and ignores '=' padding and whitespace.
export function base32Decode(input) {
if (!input) return new Uint8Array(0);
let cleaned = '';
for (let i = 0; i < input.length; i++) {
const c = input[i].toUpperCase();
if (c === '=' || c === ' ' || c === '\n' || c === '\r' || c === '\t') continue;
cleaned += c;
}
const out = [];
let bits = 0;
let value = 0;
for (let i = 0; i < cleaned.length; i++) {
const idx = BASE32_ALPHA.indexOf(cleaned[i]);
if (idx < 0) throw new Error(`base32: invalid char '${cleaned[i]}'`);
value = (value << 5) | idx;
bits += 5;
if (bits >= 8) {
bits -= 8;
out.push((value >> bits) & 0xff);
}
}
return new Uint8Array(out);
}
// parseOTPAuth extracts the base32 secret + algo/digits/period from an
// otpauth://totp/... URL. Returns defaults (SHA1, 6 digits, 30s period) for
// fields not present. Throws if `secret` is missing.
export function parseOTPAuth(url) {
if (!url || url.indexOf('otpauth://totp/') !== 0) {
throw new Error(`parseOTPAuth: not an otpauth totp url: ${url}`);
}
const q = url.indexOf('?');
if (q < 0) throw new Error('parseOTPAuth: missing query string');
const query = url.slice(q + 1);
const params = {};
for (const part of query.split('&')) {
const eq = part.indexOf('=');
if (eq < 0) continue;
const k = decodeURIComponent(part.slice(0, eq));
const v = decodeURIComponent(part.slice(eq + 1));
params[k.toLowerCase()] = v;
}
if (!params.secret) throw new Error('parseOTPAuth: missing secret param');
return {
secret: params.secret,
algorithm: (params.algorithm || 'SHA1').toUpperCase(),
digits: parseInt(params.digits || '6', 10),
period: parseInt(params.period || '30', 10),
};
}
// hotp computes an HOTP code as per RFC 4226 given a key (Uint8Array) and a
// 64-bit counter. Returns a digits-long zero-padded string.
function hotp(keyBytes, counter, digits) {
// Build 8-byte big-endian counter buffer
const buf = new ArrayBuffer(8);
const view = new DataView(buf);
// Counter can exceed Number.MAX_SAFE_INTEGER theoretically but for unix-time
// counters until year ~9999 it fits comfortably in a 32-bit low half.
const high = Math.floor(counter / 0x100000000);
const low = counter >>> 0;
view.setUint32(0, high, false);
view.setUint32(4, low, false);
// hmac with binary output gives us an ArrayBuffer of 20 bytes for SHA1
const macBuf = crypto.hmac('sha1', keyBytes.buffer, buf, 'binary');
const mac = new Uint8Array(macBuf);
const offset = mac[mac.length - 1] & 0x0f;
const bin =
((mac[offset] & 0x7f) << 24) |
((mac[offset + 1] & 0xff) << 16) |
((mac[offset + 2] & 0xff) << 8) |
(mac[offset + 3] & 0xff);
const mod = Math.pow(10, digits);
return String(bin % mod).padStart(digits, '0');
}
// generateTOTP returns the current TOTP code for the given otpauth secret.
// Pass timeOverrideMs to test specific windows; defaults to Date.now().
export function generateTOTP(otpauthURLOrSecret, timeOverrideMs) {
let opts;
if (typeof otpauthURLOrSecret === 'string' && otpauthURLOrSecret.indexOf('otpauth://') === 0) {
opts = parseOTPAuth(otpauthURLOrSecret);
} else {
opts = { secret: otpauthURLOrSecret, algorithm: 'SHA1', digits: 6, period: 30 };
}
if (opts.algorithm !== 'SHA1') {
throw new Error(`generateTOTP: only SHA1 supported, got ${opts.algorithm}`);
}
const key = base32Decode(opts.secret);
const t = typeof timeOverrideMs === 'number' ? timeOverrideMs : Date.now();
const counter = Math.floor(t / 1000 / opts.period);
return hotp(key, counter, opts.digits);
}