// 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); }