UNPKG

@squeep/totp

Version:

A minimal TOTP/HOTP helper.

294 lines (272 loc) 8.7 kB
'use strict'; const crypto = require('node:crypto'); const B32 = require('base32.js'); const QRCode = require('qrcode-svg'); const { promisify } = require('node:util'); const randomBytesAsync = promisify(crypto.randomBytes); class HMACBasedOneTimePassword { /** * * @param {object} options options * @param {Buffer|string} options.key key * @param {string=} options.keyEncoding key encoding * @param {number=} options.codeLength digits in code * @param {bigint|number|string=} options.counter initial counter value * @param {string=} options.algorithm algorithm */ constructor(options) { Object.assign(this, this.constructor._defaultOptions, options); switch (options.keyEncoding) { case 'base32': this.keyBuffer = this.constructor._b32Decode(options.key); break; case 'buffer': this.keyBuffer = options.key; break; default: this.keyBuffer = Buffer.isBuffer(options.key) ? options.key : Buffer.from(options.key, this.keyEncoding); } const expectedKeyLength = this.constructor._algorithmKeyLength(this.algorithm); if (this.keyBuffer.length !== expectedKeyLength) { throw new RangeError('key size does not match algorithm'); } if (typeof this.counter !== 'bigint') { this.counter = BigInt(this.counter); } } /** * The type used when constructing the otpauth URI. * @returns {string} otpauth type */ static get _type() { return 'hotp'; } static get _defaultOptions() { return { codeLength: 6, counter: 0n, keyEncoding: 'hex', algorithm: 'sha1', }; } static get _algorithmKeyLengths() { return { 'sha1': 20, }; } /** * * @param {string} algorithm algorithm * @returns {number} bytes */ static _algorithmKeyLength(algorithm) { if (!(this._algorithmKeyLengths[algorithm])) { // eslint-disable-line security/detect-object-injection throw new RangeError(`unsupported algorithm '${algorithm}'`); } return this._algorithmKeyLengths[algorithm]; // eslint-disable-line security/detect-object-injection } static _b32Decode(str) { const decoder = new B32.Decoder(); return decoder.finalize(str); } static _b32Encode(buf) { const encoder = new B32.Encoder(); return encoder.finalize(buf); } /** * * @param {bigint} count counter value * @returns {Buffer} hmac */ _hmac(count) { const counterBuffer = Buffer.alloc(8); counterBuffer.writeBigUInt64BE(count); return crypto.createHmac(this.algorithm, this.keyBuffer) .update(counterBuffer) .digest(); } /** * * @param {bigint} count counter value * @returns {number} partial extracted hmac */ _truncate(count) { const digest = this._hmac(count); const offset = digest[digest.length - 1] & 0x0f; return digest.readUInt32BE(offset) & 0x7fffffff; } /** * * @param {bigint=} count counter value * @returns {string} code */ generate(count) { const code = this._truncate(count ?? this.counter); const codeString = ('0'.repeat(this.codeLength + 1) + code.toString(10)).slice(0 - this.codeLength); if (count === undefined) { this.counter += 1n; } return codeString; } /** * Check a code against expected. * @param {string} hotp code to check * @param {bigint=} count counter value * @returns {boolean} is valid */ validate(hotp, count) { const codeString = this.generate(count); const codeStringB = Buffer.from(codeString); const hotpB = Buffer.from(hotp.trim()); return codeStringB.length === hotpB.length && crypto.timingSafeEqual(hotpB, codeStringB); } /** * Make a new key, of the assigned encoding. * @param {string=} algorithm algorithm * @param {string=} encoding encoding * @returns {Promise<string|Buffer>} key */ static async createKey(algorithm = 'sha1', encoding = 'hex') { const key = await randomBytesAsync(this._algorithmKeyLength(algorithm)); switch (encoding) { case 'buffer': return key; case 'base32': return this._b32Encode(key); default: return key.toString(encoding); } } /** * @typedef {object} OtpAuthData * @property {string} secret secret * @property {string} svg svg of qr otpauth uri * @property {string} uri uri */ /** * Given a key, return data suitable for an authenticator client to ingest * it, as a qrcode SVG, the otpauth uri encoded in the qrcode SVG, and the * secret key encoded as base32. * @param {object} options options * @param {string} options.accountname descriptive account name to include in uri * @param {bigint=} options.counter initial counter value * @param {string=} options.issuer issuer * @param {string=} options.scheme scheme * @param {string=} options.type type * @param {string=} options.algorithm algorithm * @param {string=} options.digits digits in code * @param {number=} options.svgPadding qr svg padding * @param {number=} options.svgWidth qr svg width * @param {number=} options.svgHeight qr svg height * @param {string=} options.svgFg qr svg foreground * @param {string=} options.svgBg qr svg background * @param {string=} options.svgEcl qr svg encoding resiliancy * @param {boolean=} options.join qr svg construction option * @param {boolean=} options.xmlDeclaration qr svg option * @param {string=} options.container qr svg option * @param {string|Buffer} key secret key * @param {string=} keyEncoding secret key encoding * @returns {OtpAuthData} otp auth */ static createKeySVG(options, key, keyEncoding = 'hex') { // Normalize key to base32 ABCDEFGHIJKLMNOPQRSTUVWXYZ234567 string (rfc4648) let keyBuffer, keyB32; switch (keyEncoding) { case 'base32': keyB32 = key; break; case 'buffer': keyBuffer = key; break; default: keyBuffer = Buffer.isBuffer(key) ? key : Buffer.from(key, keyEncoding); } if (keyBuffer) { const encoder = new B32.Encoder(); keyB32 = encoder.write(keyBuffer).finalize(); } const uri = this._qrURI({ ...options, secret: keyB32, }); const qrcode = new QRCode({ content: uri, padding: options.svgPadding ?? 4, width: options.svgWidth ?? 300, height: options.svgHeight ?? 300, color: options.svgFg || '#000000', background: options.svgBg || '#ffffff', ecl: options.svgEcl || 'M', join: options.join ?? true, xmlDeclaration: options.xmlDeclaration ?? false, container: options.container || 'svg-viewbox', }); return { secret: keyB32, svg: qrcode.svg(), uri, }; } /** * Render parameters as an otpauth URI. * @param {object} options options * @param {string} options.accountname account name * @param {string} options.secret base32 encoded secret * @param {bigint=} options.counter counter value * @param {string=} options.issuer issuer * @param {string=} options.scheme scheme * @param {string=} options.type otp auth type * @param {string=} options.algorithm algorithm * @param {string=} options.digits digits in code * @returns {string} url */ static _qrURI(options) { const { accountname, secret, counter, issuer, scheme, type, algorithm, digits, } = { ...this._qrURIDefaultOptions, ...options }; if (!accountname) { throw new RangeError('missing accountname'); } if (!secret) { throw new RangeError('missing secret'); } if (type === 'hotp' && counter === undefined) { throw new RangeError('htop must include counter'); } if (digits && ![6, 8].includes(digits)) { throw new RangeError('digits out of range'); } if (algorithm && !(algorithm.toLowerCase() in this._algorithmKeyLengths)) { throw new RangeError('unsupported algorithm'); } const label = `${issuer}${issuer ? ':' : ''}${accountname}`; const url = new URL(`${scheme}://${type}/`); url.pathname = label; Object.entries({ secret, ...(issuer && { issuer }), ...(type === 'hotp' && counter !== undefined && { counter: counter.toString }), ...(algorithm && { algorithm: algorithm.toUpperCase() }), ...(digits && { digits }), }).forEach(([k, v]) => { url.searchParams.set(k, v); }); return url.href; } static get _qrURIDefaultOptions() { return { issuer: '', scheme: 'otpauth', type: this._type, }; } } module.exports = HMACBasedOneTimePassword;