update devDependencies, fix lint issues
[squeep-totp] / lib / hotp.js
index 0fcdabcb91d8d0a0e8dad11bcfa7faf05fb0bc36..1fa41c9406301f8e2809d04a96bc7b3f6fd17cd7 100644 (file)
@@ -1,40 +1,60 @@
 'use strict';
 
-const crypto = require('crypto');
+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
-   * @param {Number=} options.codeLength
-   * @param {BigInt|Number|String=} options.counter
-   * @param {Buffer|String} options.key
-   * @param {String=} options.keyEncoding
-   * @param {String=} options.algorithm
+   * @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._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.
+   * @returns {string} otpauth type
+   */
+  static get _type() {
+    return 'hotp';
+  }
+
+  static get _defaultOptions() {
     return {
       codeLength: 6,
       counter: 0n,
-      key: undefined,
-      keyEncoding: 'ascii',
+      keyEncoding: 'hex',
       algorithm: 'sha1',
     };
   }
 
-  get _algorithmKeyLengths() {
+  static get _algorithmKeyLengths() {
     return {
       'sha1': 20,
     };
@@ -42,20 +62,30 @@ class HMACBasedOneTimePassword {
 
   /**
    * 
-   * @param {String} algorithm 
-   * @returns {Number}
+   * @param {string} algorithm algorithm
+   * @returns {number} bytes
    */
-  _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
-   * @returns {Buffer}
+   * @param {bigint} count counter value
+   * @returns {Buffer} hmac
    */
   _hmac(count) {
     const counterBuffer = Buffer.alloc(8);
@@ -67,8 +97,8 @@ class HMACBasedOneTimePassword {
 
   /**
    * 
-   * @param {BigInt} count
-   * @returns {Number}
+   * @param {bigint} count counter value
+   * @returns {number} partial extracted hmac
    */
   _truncate(count) {
     const digest = this._hmac(count);
@@ -78,8 +108,8 @@ class HMACBasedOneTimePassword {
 
   /**
    * 
-   * @param {BigInt=} count
-   * @returns {String}
+   * @param {bigint=} count counter value
+   * @returns {string} code
    */
   generate(count) {
     const code = this._truncate(count ?? this.counter);
@@ -91,16 +121,170 @@ class HMACBasedOneTimePassword {
   }
 
   /**
-   * 
-   * @param {String} hotp
-   * @param {BigInt=} count
-   * @returns {Boolean}
+   * 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);
     return codeString === hotp.trim();
   }
 
+  /**
+   * 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;
\ No newline at end of file
+module.exports = HMACBasedOneTimePassword;