bump package version to 1.1.5
[squeep-totp] / lib / hotp.js
1 'use strict';
2
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);
8
9 class HMACBasedOneTimePassword {
10 /**
11 *
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
18 */
19 constructor(options) {
20 Object.assign(this, this.constructor._defaultOptions, options);
21 switch (options.keyEncoding) {
22 case 'base32':
23 this.keyBuffer = this.constructor._b32Decode(options.key);
24 break;
25 case 'buffer':
26 this.keyBuffer = options.key;
27 break;
28 default:
29 this.keyBuffer = Buffer.isBuffer(options.key) ? options.key : Buffer.from(options.key, this.keyEncoding);
30 }
31 const expectedKeyLength = this.constructor._algorithmKeyLength(this.algorithm);
32 if (this.keyBuffer.length !== expectedKeyLength) {
33 throw new RangeError('key size does not match algorithm');
34 }
35 if (typeof this.counter !== 'bigint') {
36 this.counter = BigInt(this.counter);
37 }
38 }
39
40 /**
41 * The type used when constructing the otpauth URI.
42 */
43 static get _type() {
44 return 'hotp';
45 }
46
47 static get _defaultOptions() {
48 return {
49 codeLength: 6,
50 counter: 0n,
51 keyEncoding: 'hex',
52 algorithm: 'sha1',
53 };
54 }
55
56 static get _algorithmKeyLengths() {
57 return {
58 'sha1': 20,
59 };
60 }
61
62 /**
63 *
64 * @param {String} algorithm
65 * @returns {Number}
66 */
67 static _algorithmKeyLength(algorithm) {
68 if (!(this._algorithmKeyLengths[algorithm])) { // eslint-disable-line security/detect-object-injection
69 throw new RangeError(`unsupported algorithm '${algorithm}'`);
70 }
71 return this._algorithmKeyLengths[algorithm]; // eslint-disable-line security/detect-object-injection
72 }
73
74 static _b32Decode(str) {
75 const decoder = new B32.Decoder();
76 return decoder.finalize(str);
77 }
78
79 static _b32Encode(buf) {
80 const encoder = new B32.Encoder();
81 return encoder.finalize(buf);
82 }
83
84 /**
85 *
86 * @param {BigInt} count
87 * @returns {Buffer}
88 */
89 _hmac(count) {
90 const counterBuffer = Buffer.alloc(8);
91 counterBuffer.writeBigUInt64BE(count);
92 return crypto.createHmac(this.algorithm, this.keyBuffer)
93 .update(counterBuffer)
94 .digest();
95 }
96
97 /**
98 *
99 * @param {BigInt} count
100 * @returns {Number}
101 */
102 _truncate(count) {
103 const digest = this._hmac(count);
104 const offset = digest[digest.length - 1] & 0x0f;
105 return digest.readUInt32BE(offset) & 0x7fffffff;
106 }
107
108 /**
109 *
110 * @param {BigInt=} count
111 * @returns {String}
112 */
113 generate(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) {
117 this.counter += 1n;
118 }
119 return codeString;
120 }
121
122 /**
123 * Check a code against expected.
124 * @param {String} hotp
125 * @param {BigInt=} count
126 * @returns {Boolean}
127 */
128 validate(hotp, count) {
129 const codeString = this.generate(count);
130 return codeString === hotp.trim();
131 }
132
133 /**
134 * Make a new key, of the assigned encoding.
135 * @param {String=} encoding
136 * @param {String=} algorithm
137 * @returns {Promise<String|Buffer>}
138 */
139 static async createKey(algorithm = 'sha1', encoding = 'hex') {
140 const key = await randomBytesAsync(this._algorithmKeyLength(algorithm));
141 switch (encoding) {
142 case 'buffer':
143 return key;
144 case 'base32':
145 return this._b32Encode(key);
146 default:
147 return key.toString(encoding);
148 }
149 }
150
151 /**
152 * @typedef {Object} OtpAuthData
153 * @property {String} secret
154 * @property {String} svg
155 * @property {String} uri
156 */
157 /**
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}
181 */
182 static createKeySVG(options, key, keyEncoding = 'hex') {
183 // Normalize key to base32 ABCDEFGHIJKLMNOPQRSTUVWXYZ234567 string (rfc4648)
184 let keyBuffer, keyB32;
185 switch (keyEncoding) {
186 case 'base32':
187 keyB32 = key;
188 break;
189 case 'buffer':
190 keyBuffer = key;
191 break;
192 default:
193 keyBuffer = Buffer.isBuffer(key) ? key : Buffer.from(key, keyEncoding);
194 }
195 if (keyBuffer) {
196 const encoder = new B32.Encoder();
197 keyB32 = encoder.write(keyBuffer).finalize();
198 }
199 const uri = this._qrURI({
200 ...options,
201 secret: keyB32,
202 });
203
204 const qrcode = new QRCode({
205 content: uri,
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',
215 });
216
217 return {
218 secret: keyB32,
219 svg: qrcode.svg(),
220 uri,
221 };
222 }
223
224 /**
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
235 */
236 static _qrURI(options) {
237 const {
238 accountname,
239 secret,
240 counter,
241 issuer,
242 scheme,
243 type,
244 algorithm,
245 digits,
246 } = { ...this._qrURIDefaultOptions, ...options };
247 if (!accountname) {
248 throw new RangeError('missing accountname');
249 }
250 if (!secret) {
251 throw new RangeError('missing secret');
252 }
253 if (type === 'hotp' && counter === undefined) {
254 throw new RangeError('htop must include counter');
255 }
256 if (digits && ![6, 8].includes(digits)) {
257 throw new RangeError('digits out of range');
258 }
259 if (algorithm && !(algorithm.toLowerCase() in this._algorithmKeyLengths)) {
260 throw new RangeError('unsupported algorithm');
261 }
262 const label = `${issuer}${issuer ? ':' : ''}${accountname}`;
263 const url = new URL(`${scheme}://${type}/`);
264 url.pathname = label;
265 Object.entries({
266 secret,
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);
273 });
274
275 return url.href;
276 }
277
278 static get _qrURIDefaultOptions() {
279 return {
280 issuer: '',
281 scheme: 'otpauth',
282 type: this._type,
283 };
284 }
285
286 }
287
288 module.exports = HMACBasedOneTimePassword;