add createKey and createKeySVG methods, support base32 key encoding
[squeep-totp] / lib / hotp.js
index 0fcdabcb91d8d0a0e8dad11bcfa7faf05fb0bc36..e93f7203c41ce04ce38ba4d84182471913db00a5 100644 (file)
@@ -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,152 @@ 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 {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',
+    });
+
+    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