3 const crypto
= require('node:crypto');
4 const B32
= require('base32.js');
5 const QRCode
= require('qrcode-svg');
6 const { promisify
} = require('util');
7 const randomBytesAsync
= promisify(crypto
.randomBytes
);
9 class HMACBasedOneTimePassword
{
12 * @param {Object} options
13 * @param {Buffer|String} options.key
14 * @param {String=} options.keyEncoding
15 * @param {Number=} options.codeLength
16 * @param {BigInt|Number|String=} options.counter
17 * @param {String=} options.algorithm
19 constructor(options
) {
20 Object
.assign(this, this.constructor._defaultOptions
, options
);
21 switch (options
.keyEncoding
) {
23 this.keyBuffer
= this.constructor._b32Decode(options
.key
);
26 this.keyBuffer
= options
.key
;
29 this.keyBuffer
= Buffer
.isBuffer(options
.key
) ? options
.key : Buffer
.from(options
.key
, this.keyEncoding
);
31 const expectedKeyLength
= this.constructor._algorithmKeyLength(this.algorithm
);
32 if (this.keyBuffer
.length
!== expectedKeyLength
) {
33 throw new RangeError('key size does not match algorithm');
35 if (typeof this.counter
!== 'bigint') {
36 this.counter
= BigInt(this.counter
);
41 * The type used when constructing the otpauth URI.
47 static get _defaultOptions() {
56 static get _algorithmKeyLengths() {
64 * @param {String} algorithm
67 static _algorithmKeyLength(algorithm
) {
68 if (!(this._algorithmKeyLengths
[algorithm
])) { // eslint-disable-line security/detect-object-injection
69 throw new RangeError(`unsupported algorithm '${algorithm}'`);
71 return this._algorithmKeyLengths
[algorithm
]; // eslint-disable-line security/detect-object-injection
74 static _b32Decode(str
) {
75 const decoder
= new B32
.Decoder();
76 return decoder
.finalize(str
);
79 static _b32Encode(buf
) {
80 const encoder
= new B32
.Encoder();
81 return encoder
.finalize(buf
);
86 * @param {BigInt} count
90 const counterBuffer
= Buffer
.alloc(8);
91 counterBuffer
.writeBigUInt64BE(count
);
92 return crypto
.createHmac(this.algorithm
, this.keyBuffer
)
93 .update(counterBuffer
)
99 * @param {BigInt} count
103 const digest
= this._hmac(count
);
104 const offset
= digest
[digest
.length
- 1] & 0x0f;
105 return digest
.readUInt32BE(offset
) & 0x7fffffff;
110 * @param {BigInt=} count
114 const code
= this._truncate(count
?? this.counter
);
115 const codeString
= ('0'.repeat(this.codeLength
+ 1) + code
.toString(10)).slice(0 - this.codeLength
);
116 if (count
=== undefined) {
123 * Check a code against expected.
124 * @param {String} hotp
125 * @param {BigInt=} count
128 validate(hotp
, count
) {
129 const codeString
= this.generate(count
);
130 return codeString
=== hotp
.trim();
134 * Make a new key, of the assigned encoding.
135 * @param {String=} encoding
136 * @param {String=} algorithm
137 * @returns {Promise<String|Buffer>}
139 static async
createKey(algorithm
= 'sha1', encoding
= 'hex') {
140 const key
= await
randomBytesAsync(this._algorithmKeyLength(algorithm
));
145 return this._b32Encode(key
);
147 return key
.toString(encoding
);
152 * @typedef {Object} OtpAuthData
153 * @property {String} secret
154 * @property {String} svg
155 * @property {String} uri
158 * Given a key, return data suitable for an authenticator client to ingest
159 * it, as a qrcode SVG, the otpauth uri encoded in the qrcode SVG, and the
160 * secret key encoded as base32.
161 * @param {Object} options
162 * @param {String} options.accountname
163 * @param {BigInt=} options.counter
164 * @param {String=} options.issuer
165 * @param {String=} options.scheme
166 * @param {String=} options.type
167 * @param {String=} options.algorithm
168 * @param {String=} options.digits
169 * @param {Number=} options.svgPadding
170 * @param {Number=} options.svgWidth
171 * @param {Number=} options.svgHeight
172 * @param {String=} options.svgFg
173 * @param {String=} options.svgBg
174 * @param {String=} options.svgEcl
175 * @param {Boolean=} options.join
176 * @param {Boolean=} options.xmlDeclaration
177 * @param {String=} options.container
178 * @param {String|Buffer} key
179 * @param {String=} keyEncoding
180 * @returns {OtpAuthData}
182 static createKeySVG(options
, key
, keyEncoding
= 'hex') {
183 // Normalize key to base32 ABCDEFGHIJKLMNOPQRSTUVWXYZ234567 string (rfc4648)
184 let keyBuffer
, keyB32
;
185 switch (keyEncoding
) {
193 keyBuffer
= Buffer
.isBuffer(key
) ? key : Buffer
.from(key
, keyEncoding
);
196 const encoder
= new B32
.Encoder();
197 keyB32
= encoder
.write(keyBuffer
).finalize();
199 const uri
= this._qrURI({
204 const qrcode
= new QRCode({
206 padding: options
.svgPadding
?? 4,
207 width: options
.svgWidth
?? 300,
208 height: options
.svgHeight
?? 300,
209 color: options
.svgFg
|| '#000000',
210 background: options
.svgBg
|| '#ffffff',
211 ecl: options
.svgEcl
|| 'M',
212 join: options
.join
?? true,
213 xmlDeclaration: options
.xmlDeclaration
?? false,
214 container: options
.container
|| 'svg-viewbox',
225 * Render parameters as an otpauth URI.
226 * @param {Object} options
227 * @param {String} options.accountname
228 * @param {String} options.secret base32
229 * @param {BigInt=} options.counter
230 * @param {String=} options.issuer
231 * @param {String=} options.scheme
232 * @param {String=} options.type
233 * @param {String=} options.algorithm
234 * @param {String=} options.digits
236 static _qrURI(options
) {
246 } = { ...this._qrURIDefaultOptions
, ...options
};
248 throw new RangeError('missing accountname');
251 throw new RangeError('missing secret');
253 if (type
=== 'hotp' && counter
=== undefined) {
254 throw new RangeError('htop must include counter');
256 if (digits
&& ![6, 8].includes(digits
)) {
257 throw new RangeError('digits out of range');
259 if (algorithm
&& !(algorithm
.toLowerCase() in this._algorithmKeyLengths
)) {
260 throw new RangeError('unsupported algorithm');
262 const label
= `${issuer}${issuer ? ':' : ''}${accountname}`;
263 const url
= new URL(`${scheme}://${type}/`);
264 url
.pathname
= label
;
267 ...(issuer
&& { issuer
}),
268 ...(type
=== 'hotp' && counter
!== undefined && { counter: counter
.toString
}),
269 ...(algorithm
&& { algorithm: algorithm
.toUpperCase() }),
270 ...(digits
&& { digits
}),
271 }).forEach(([k
, v
]) => {
272 url
.searchParams
.set(k
, v
);
278 static get _qrURIDefaultOptions() {
288 module
.exports
= HMACBasedOneTimePassword
;