'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,
};
* @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
}
/**
- *
+ * Check a code against expected.
* @param {String} hotp
* @param {BigInt=} count
* @returns {Boolean}
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;