@squeep/totp
Version:
A minimal TOTP/HOTP helper.
294 lines (272 loc) • 8.7 kB
JavaScript
;
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;