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('node:util');
7 const randomBytesAsync = promisify(crypto.randomBytes);
8
9 class HMACBasedOneTimePassword {
10 /**
11 *
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
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 * @returns {string} otpauth type
43 */
44 static get _type() {
45 return 'hotp';
46 }
47
48 static get _defaultOptions() {
49 return {
50 codeLength: 6,
51 counter: 0n,
52 keyEncoding: 'hex',
53 algorithm: 'sha1',
54 };
55 }
56
57 static get _algorithmKeyLengths() {
58 return {
59 'sha1': 20,
60 };
61 }
62
63 /**
64 *
65 * @param {string} algorithm algorithm
66 * @returns {number} bytes
67 */
68 static _algorithmKeyLength(algorithm) {
69 if (!(this._algorithmKeyLengths[algorithm])) { // eslint-disable-line security/detect-object-injection
70 throw new RangeError(`unsupported algorithm '${algorithm}'`);
71 }
72 return this._algorithmKeyLengths[algorithm]; // eslint-disable-line security/detect-object-injection
73 }
74
75 static _b32Decode(str) {
76 const decoder = new B32.Decoder();
77 return decoder.finalize(str);
78 }
79
80 static _b32Encode(buf) {
81 const encoder = new B32.Encoder();
82 return encoder.finalize(buf);
83 }
84
85 /**
86 *
87 * @param {bigint} count counter value
88 * @returns {Buffer} hmac
89 */
90 _hmac(count) {
91 const counterBuffer = Buffer.alloc(8);
92 counterBuffer.writeBigUInt64BE(count);
93 return crypto.createHmac(this.algorithm, this.keyBuffer)
94 .update(counterBuffer)
95 .digest();
96 }
97
98 /**
99 *
100 * @param {bigint} count counter value
101 * @returns {number} partial extracted hmac
102 */
103 _truncate(count) {
104 const digest = this._hmac(count);
105 const offset = digest[digest.length - 1] & 0x0f;
106 return digest.readUInt32BE(offset) & 0x7fffffff;
107 }
108
109 /**
110 *
111 * @param {bigint=} count counter value
112 * @returns {string} code
113 */
114 generate(count) {
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) {
118 this.counter += 1n;
119 }
120 return codeString;
121 }
122
123 /**
124 * Check a code against expected.
125 * @param {string} hotp code to check
126 * @param {bigint=} count counter value
127 * @returns {boolean} is valid
128 */
129 validate(hotp, count) {
130 const codeString = this.generate(count);
131 return codeString === hotp.trim();
132 }
133
134 /**
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
139 */
140 static async createKey(algorithm = 'sha1', encoding = 'hex') {
141 const key = await randomBytesAsync(this._algorithmKeyLength(algorithm));
142 switch (encoding) {
143 case 'buffer':
144 return key;
145 case 'base32':
146 return this._b32Encode(key);
147 default:
148 return key.toString(encoding);
149 }
150 }
151
152 /**
153 * @typedef {object} OtpAuthData
154 * @property {string} secret secret
155 * @property {string} svg svg of qr otpauth uri
156 * @property {string} uri uri
157 */
158 /**
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
182 */
183 static createKeySVG(options, key, keyEncoding = 'hex') {
184 // Normalize key to base32 ABCDEFGHIJKLMNOPQRSTUVWXYZ234567 string (rfc4648)
185 let keyBuffer, keyB32;
186 switch (keyEncoding) {
187 case 'base32':
188 keyB32 = key;
189 break;
190 case 'buffer':
191 keyBuffer = key;
192 break;
193 default:
194 keyBuffer = Buffer.isBuffer(key) ? key : Buffer.from(key, keyEncoding);
195 }
196 if (keyBuffer) {
197 const encoder = new B32.Encoder();
198 keyB32 = encoder.write(keyBuffer).finalize();
199 }
200 const uri = this._qrURI({
201 ...options,
202 secret: keyB32,
203 });
204
205 const qrcode = new QRCode({
206 content: uri,
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',
216 });
217
218 return {
219 secret: keyB32,
220 svg: qrcode.svg(),
221 uri,
222 };
223 }
224
225 /**
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
237 */
238 static _qrURI(options) {
239 const {
240 accountname,
241 secret,
242 counter,
243 issuer,
244 scheme,
245 type,
246 algorithm,
247 digits,
248 } = { ...this._qrURIDefaultOptions, ...options };
249 if (!accountname) {
250 throw new RangeError('missing accountname');
251 }
252 if (!secret) {
253 throw new RangeError('missing secret');
254 }
255 if (type === 'hotp' && counter === undefined) {
256 throw new RangeError('htop must include counter');
257 }
258 if (digits && ![6, 8].includes(digits)) {
259 throw new RangeError('digits out of range');
260 }
261 if (algorithm && !(algorithm.toLowerCase() in this._algorithmKeyLengths)) {
262 throw new RangeError('unsupported algorithm');
263 }
264 const label = `${issuer}${issuer ? ':' : ''}${accountname}`;
265 const url = new URL(`${scheme}://${type}/`);
266 url.pathname = label;
267 Object.entries({
268 secret,
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);
275 });
276
277 return url.href;
278 }
279
280 static get _qrURIDefaultOptions() {
281 return {
282 issuer: '',
283 scheme: 'otpauth',
284 type: this._type,
285 };
286 }
287
288 }
289
290 module.exports = HMACBasedOneTimePassword;