X-Git-Url: http://git.squeep.com/?a=blobdiff_plain;f=lib%2Fhotp.js;h=0e20145e4c2c11aec6c9e84bcbb6b48851a68ae2;hb=9ddf23a4e87c2e12bb088b9e88ce36676f944273;hp=0fcdabcb91d8d0a0e8dad11bcfa7faf05fb0bc36;hpb=2207e51e71606cbd1b4e3a688d450a79853dc8e9;p=squeep-totp diff --git a/lib/hotp.js b/lib/hotp.js index 0fcdabc..0e20145 100644 --- a/lib/hotp.js +++ b/lib/hotp.js @@ -1,40 +1,59 @@ 'use strict'; -const crypto = require('crypto'); +const crypto = require('node:crypto'); +const B32 = require('base32.js'); +const QRCode = require('qrcode-svg'); +const { promisify } = require('util'); +const randomBytesAsync = promisify(crypto.randomBytes); class HMACBasedOneTimePassword { /** * * @param {Object} options - * @param {Number=} options.codeLength - * @param {BigInt|Number|String=} options.counter * @param {Buffer|String} options.key * @param {String=} options.keyEncoding + * @param {Number=} options.codeLength + * @param {BigInt|Number|String=} options.counter * @param {String=} options.algorithm */ constructor(options) { - Object.assign(this, this._defaultOptions, options); - this.keyBuffer = Buffer.isBuffer(this.key) ? this.key : Buffer.from(this.key, this.keyEncoding); - const expectedKeyLength = this._algorithmKeyLength(this.algorithm); + 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') { + if (typeof this.counter !== 'bigint') { this.counter = BigInt(this.counter); } } - get _defaultOptions() { + /** + * The type used when constructing the otpauth URI. + */ + static get _type() { + return 'hotp'; + } + + static get _defaultOptions() { return { codeLength: 6, counter: 0n, - key: undefined, keyEncoding: 'ascii', algorithm: 'sha1', }; } - get _algorithmKeyLengths() { + static get _algorithmKeyLengths() { return { 'sha1': 20, }; @@ -45,13 +64,23 @@ class HMACBasedOneTimePassword { * @param {String} algorithm * @returns {Number} */ - _algorithmKeyLength(algorithm) { - if (!(this._algorithmKeyLengths[algorithm])) { + 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 @@ -91,7 +120,7 @@ class HMACBasedOneTimePassword { } /** - * + * Check a code against expected. * @param {String} hotp * @param {BigInt=} count * @returns {Boolean} @@ -101,6 +130,158 @@ class HMACBasedOneTimePassword { return codeString === hotp.trim(); } + /** + * Make a new key, of the assigned encoding. + * @param {String=} encoding + * @param {String=} algorithm + * @returns {String|Buffer} + */ + 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 + * @property {String} svg + * @property {String} 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 + * @param {String} options.accountname + * @param {BigInt=} options.counter + * @param {String=} options.issuer + * @param {String=} options.scheme + * @param {String=} options.type + * @param {String=} options.algorithm + * @param {String=} options.digits + * @param {Number=} options.svgPadding + * @param {Number=} options.svgWidth + * @param {Number=} options.svgHeight + * @param {String=} options.svgFg + * @param {String=} options.svgBg + * @param {String=} options.svgEcl + * @param {Boolean=} options.join + * @param {Boolean=} options.xmlDeclaration + * @param {String=} options.container + * @param {String|Buffer} key + * @param {String=} keyEncoding + * @returns {OtpAuthData} + */ + 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 + * @param {String} options.accountname + * @param {String} options.secret base32 + * @param {BigInt=} options.counter + * @param {String=} options.issuer + * @param {String=} options.scheme + * @param {String=} options.type + * @param {String=} options.algorithm + * @param {String=} options.digits + */ + 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 { + scheme: 'otpauth', + type: this._type, + }; + } + } -module.exports = HMACBasedOneTimePassword; \ No newline at end of file +module.exports = HMACBasedOneTimePassword;