3 const crypto
= require('node:crypto');
4 const B32
= require('base32.js');
5 const QRCode
= require('qrcode-svg');
6 const { promisify
} = require('node:util');
7 const randomBytesAsync
= promisify(crypto
.randomBytes
);
9 class HMACBasedOneTimePassword
{
12 * @param {object} options options
13 * @param {Buffer|string} options.key key
14 * @param {string=} options.keyEncoding key encoding
15 * @param {number=} options.codeLength digits in code
16 * @param {bigint|number|string=} options.counter initial counter value
17 * @param {string=} options.algorithm 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.
42 * @returns {string} otpauth type
48 static get _defaultOptions() {
57 static get _algorithmKeyLengths() {
65 * @param {string} algorithm algorithm
66 * @returns {number} bytes
68 static _algorithmKeyLength(algorithm
) {
69 if (!(this._algorithmKeyLengths
[algorithm
])) { // eslint-disable-line security/detect-object-injection
70 throw new RangeError(`unsupported algorithm '${algorithm}'`);
72 return this._algorithmKeyLengths
[algorithm
]; // eslint-disable-line security/detect-object-injection
75 static _b32Decode(str
) {
76 const decoder
= new B32
.Decoder();
77 return decoder
.finalize(str
);
80 static _b32Encode(buf
) {
81 const encoder
= new B32
.Encoder();
82 return encoder
.finalize(buf
);
87 * @param {bigint} count counter value
88 * @returns {Buffer} hmac
91 const counterBuffer
= Buffer
.alloc(8);
92 counterBuffer
.writeBigUInt64BE(count
);
93 return crypto
.createHmac(this.algorithm
, this.keyBuffer
)
94 .update(counterBuffer
)
100 * @param {bigint} count counter value
101 * @returns {number} partial extracted hmac
104 const digest
= this._hmac(count
);
105 const offset
= digest
[digest
.length
- 1] & 0x0f;
106 return digest
.readUInt32BE(offset
) & 0x7fffffff;
111 * @param {bigint=} count counter value
112 * @returns {string} code
115 const code
= this._truncate(count
?? this.counter
);
116 const codeString
= ('0'.repeat(this.codeLength
+ 1) + code
.toString(10)).slice(0 - this.codeLength
);
117 if (count
=== undefined) {
124 * Check a code against expected.
125 * @param {string} hotp code to check
126 * @param {bigint=} count counter value
127 * @returns {boolean} is valid
129 validate(hotp
, count
) {
130 const codeString
= this.generate(count
);
131 return codeString
=== hotp
.trim();
135 * Make a new key, of the assigned encoding.
136 * @param {string=} algorithm algorithm
137 * @param {string=} encoding encoding
138 * @returns {Promise<string|Buffer>} key
140 static async
createKey(algorithm
= 'sha1', encoding
= 'hex') {
141 const key
= await
randomBytesAsync(this._algorithmKeyLength(algorithm
));
146 return this._b32Encode(key
);
148 return key
.toString(encoding
);
153 * @typedef {object} OtpAuthData
154 * @property {string} secret secret
155 * @property {string} svg svg of qr otpauth uri
156 * @property {string} uri uri
159 * Given a key, return data suitable for an authenticator client to ingest
160 * it, as a qrcode SVG, the otpauth uri encoded in the qrcode SVG, and the
161 * secret key encoded as base32.
162 * @param {object} options options
163 * @param {string} options.accountname descriptive account name to include in uri
164 * @param {bigint=} options.counter initial counter value
165 * @param {string=} options.issuer issuer
166 * @param {string=} options.scheme scheme
167 * @param {string=} options.type type
168 * @param {string=} options.algorithm algorithm
169 * @param {string=} options.digits digits in code
170 * @param {number=} options.svgPadding qr svg padding
171 * @param {number=} options.svgWidth qr svg width
172 * @param {number=} options.svgHeight qr svg height
173 * @param {string=} options.svgFg qr svg foreground
174 * @param {string=} options.svgBg qr svg background
175 * @param {string=} options.svgEcl qr svg encoding resiliancy
176 * @param {boolean=} options.join qr svg construction option
177 * @param {boolean=} options.xmlDeclaration qr svg option
178 * @param {string=} options.container qr svg option
179 * @param {string|Buffer} key secret key
180 * @param {string=} keyEncoding secret key encoding
181 * @returns {OtpAuthData} otp auth
183 static createKeySVG(options
, key
, keyEncoding
= 'hex') {
184 // Normalize key to base32 ABCDEFGHIJKLMNOPQRSTUVWXYZ234567 string (rfc4648)
185 let keyBuffer
, keyB32
;
186 switch (keyEncoding
) {
194 keyBuffer
= Buffer
.isBuffer(key
) ? key : Buffer
.from(key
, keyEncoding
);
197 const encoder
= new B32
.Encoder();
198 keyB32
= encoder
.write(keyBuffer
).finalize();
200 const uri
= this._qrURI({
205 const qrcode
= new QRCode({
207 padding: options
.svgPadding
?? 4,
208 width: options
.svgWidth
?? 300,
209 height: options
.svgHeight
?? 300,
210 color: options
.svgFg
|| '#000000',
211 background: options
.svgBg
|| '#ffffff',
212 ecl: options
.svgEcl
|| 'M',
213 join: options
.join
?? true,
214 xmlDeclaration: options
.xmlDeclaration
?? false,
215 container: options
.container
|| 'svg-viewbox',
226 * Render parameters as an otpauth URI.
227 * @param {object} options options
228 * @param {string} options.accountname account name
229 * @param {string} options.secret base32 encoded secret
230 * @param {bigint=} options.counter counter value
231 * @param {string=} options.issuer issuer
232 * @param {string=} options.scheme scheme
233 * @param {string=} options.type otp auth type
234 * @param {string=} options.algorithm algorithm
235 * @param {string=} options.digits digits in code
236 * @returns {string} url
238 static _qrURI(options
) {
248 } = { ...this._qrURIDefaultOptions
, ...options
};
250 throw new RangeError('missing accountname');
253 throw new RangeError('missing secret');
255 if (type
=== 'hotp' && counter
=== undefined) {
256 throw new RangeError('htop must include counter');
258 if (digits
&& ![6, 8].includes(digits
)) {
259 throw new RangeError('digits out of range');
261 if (algorithm
&& !(algorithm
.toLowerCase() in this._algorithmKeyLengths
)) {
262 throw new RangeError('unsupported algorithm');
264 const label
= `${issuer}${issuer ? ':' : ''}${accountname}`;
265 const url
= new URL(`${scheme}://${type}/`);
266 url
.pathname
= label
;
269 ...(issuer
&& { issuer
}),
270 ...(type
=== 'hotp' && counter
!== undefined && { counter: counter
.toString
}),
271 ...(algorithm
&& { algorithm: algorithm
.toUpperCase() }),
272 ...(digits
&& { digits
}),
273 }).forEach(([k
, v
]) => {
274 url
.searchParams
.set(k
, v
);
280 static get _qrURIDefaultOptions() {
290 module
.exports
= HMACBasedOneTimePassword
;