e93f7203c41ce04ce38ba4d84182471913db00a5
[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: 'ascii',
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 {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 {String|Buffer} key
176 * @param {String=} keyEncoding
177 * @returns {OtpAuthData}
178 */
179 static createKeySVG(options, key, keyEncoding = 'hex') {
180 // Normalize key to base32 ABCDEFGHIJKLMNOPQRSTUVWXYZ234567 string (rfc4648)
181 let keyBuffer, keyB32;
182 switch (keyEncoding) {
183 case 'base32':
184 keyB32 = key;
185 break;
186 case 'buffer':
187 keyBuffer = key;
188 break;
189 default:
190 keyBuffer = Buffer.isBuffer(key) ? key : Buffer.from(key, keyEncoding);
191 }
192 if (keyBuffer) {
193 const encoder = new B32.Encoder();
194 keyB32 = encoder.write(keyBuffer).finalize();
195 }
196 const uri = this._qrURI({
197 ...options,
198 secret: keyB32,
199 });
200
201 const qrcode = new QRCode({
202 content: uri,
203 padding: options.svgPadding ?? 4,
204 width: options.svgWidth ?? 300,
205 height: options.svgHeight ?? 300,
206 color: options.svgFg || '#000000',
207 background: options.svgBg || '#ffffff',
208 ecl: options.svgEcl || 'M',
209 });
210
211 return {
212 secret: keyB32,
213 svg: qrcode.svg(),
214 uri,
215 };
216 }
217
218 /**
219 * Render parameters as an otpauth URI.
220 * @param {Object} options
221 * @param {String} options.accountname
222 * @param {String} options.secret base32
223 * @param {BigInt=} options.counter
224 * @param {String=} options.issuer
225 * @param {String=} options.scheme
226 * @param {String=} options.type
227 * @param {String=} options.algorithm
228 * @param {String=} options.digits
229 */
230 static _qrURI(options) {
231 const {
232 accountname,
233 secret,
234 counter,
235 issuer,
236 scheme,
237 type,
238 algorithm,
239 digits,
240 } = { ...this._qrURIDefaultOptions, ...options };
241 if (!accountname) {
242 throw new RangeError('missing accountname');
243 }
244 if (!secret) {
245 throw new RangeError('missing secret');
246 }
247 if (type === 'hotp' && counter === undefined) {
248 throw new RangeError('htop must include counter');
249 }
250 if (digits && ![6, 8].includes(digits)) {
251 throw new RangeError('digits out of range');
252 }
253 if (algorithm && !(algorithm.toLowerCase() in this._algorithmKeyLengths)) {
254 throw new RangeError('unsupported algorithm');
255 }
256 const label = `${issuer}${issuer ? ':' : ''}${accountname}`;
257 const url = new URL(`${scheme}://${type}/`);
258 url.pathname = label;
259 Object.entries({
260 secret,
261 ...(issuer && { issuer }),
262 ...(type === 'hotp' && counter !== undefined && { counter: counter.toString }),
263 ...(algorithm && { algorithm: algorithm.toUpperCase() }),
264 ...(digits && { digits }),
265 }).forEach(([k, v]) => {
266 url.searchParams.set(k, v);
267 });
268
269 return url.href;
270 }
271
272 static get _qrURIDefaultOptions() {
273 return {
274 scheme: 'otpauth',
275 type: this._type,
276 };
277 }
278
279 }
280
281 module.exports = HMACBasedOneTimePassword;