@punikonta/node-otp 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE.md ADDED
@@ -0,0 +1,9 @@
1
+ The MIT License
2
+
3
+ Copyright (c) Marcel Jovic <punikonta@protonmail.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,182 @@
1
+ # Dependency free OTP implementation
2
+
3
+ Simple and dependency free OTP implementation (HOTP, TOTP) for Node.js written in TypeScript. Passes RFC test vectors and works with common authenticator apps.
4
+
5
+ ## Features
6
+
7
+ - HOTP
8
+ - TOTP (builds on top of HOTP)
9
+ - `otpauth://` URL generation
10
+
11
+ Generating QR codes for `otpauth://` URLs is **not** within the scope of this library. There are [plenty of good QR code libraries](https://www.npmjs.com/search?q=qr) out there. Just run your URL through one of them and you're good to go. There's an example QR code that works in conjunction with the example code below if you want to test it with your own authenticator app, though.
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ npm install @punikonta/node-otp
17
+ ```
18
+
19
+ ## Example
20
+
21
+ Example usage for TOTP in plain JavaScript:
22
+
23
+ ```javascript
24
+ import Otp from '@punikonta/node-otp'
25
+
26
+ // example 128 bit secret for demonstration only! don't use this in your application.
27
+ // this must be random, stored securely and be unique for each user.
28
+ const secret = Buffer.from('8eddb53e05fa936c2530c8045a58f81b', 'hex')
29
+
30
+ // options are optional. these are the defaults and common practice.
31
+ // just omit the options parameter if you're fine with the defaults.
32
+ const options = {
33
+ algorithm: Otp.HashAlgorithm.SHA1,
34
+ digits: 6,
35
+ period: 30,
36
+ }
37
+
38
+ const now = Date.now()
39
+
40
+ const token = Otp.Totp.generate(secret, now, options)
41
+ const remaining = Otp.Totp.remaining(options.period, now)
42
+ const url = Otp.Url.getTotpUrl('example.com', 'foobar', secret, options)
43
+
44
+ // default value. omit if you're fine with the default.
45
+ const window = 1
46
+ const valid = Otp.Totp.validate('123456', secret, now, window, options)
47
+
48
+ console.log(`token: ${token}`)
49
+ console.log(`valid for ${remaining.toFixed(2)} seconds`)
50
+ console.log(url)
51
+ console.log(`is 123456 valid? ${valid ? 'yes' : 'no'}`)
52
+ ```
53
+
54
+ If you want to verify the example right away with an authenticator app, you can use the following QR code without having to generate one yourself:
55
+
56
+ ![example QR code](assets/example.png)
57
+
58
+ `otpauth://totp/example.com:foobar?secret=R3O3KPQF7KJWYJJQZACFUWHYDM&algorithm=SHA1&digits=6&period=30&issuer=example.com`
59
+
60
+ ## Motivation
61
+
62
+ I wanted a project with a small scope to get my hands dirty with TypeScript and NPM packaging. Also I've been looking for a TOTP library at the same time. I couldn't find one that I liked, so I decided to create my own. TOTP builds on top of HOTP, so I implemented that as well.
63
+
64
+ ## Remarks
65
+
66
+ I've successfully tested the TOTP functionality with the following authenticator apps:
67
+
68
+ - Authy
69
+ - Google Authenticator
70
+ - Microsoft Authenticator
71
+ - Proton Authenticator
72
+ - Bitwarden Authenticator
73
+
74
+ Double check if you want to use anything but `SHA1` as your hash algorithm. Some popular authenticator apps don't support anything else and even go as far as completely ignoring that parameter, silently giving the user wrong tokens. To catch this and other issues early, your onboarding flow should implement a successful token readback while setting up 2FA anyway.
75
+
76
+ Also don't use a `window` value other than `0` for HOTP unless you know what you're doing. Managing the HOTP counter is up to you.
77
+
78
+ The default `window` value of `1` for TOTP generally is fine and common practice to mitigate small clock drifts between the server and the user.
79
+
80
+ ## API
81
+
82
+ The API uses the following types and enums:
83
+
84
+ ```typescript
85
+ enum HashAlgorithm {
86
+ SHA1 = 'sha1',
87
+ SHA256 = 'sha256',
88
+ SHA384 = 'sha384',
89
+ SHA512 = 'sha512',
90
+ }
91
+
92
+ type HotpOptions = {
93
+ algorithm: HashAlgorithm
94
+ digits: number
95
+ }
96
+
97
+ type TotpOptions = {
98
+ algorithm: HashAlgorithm
99
+ digits: number
100
+ period: number
101
+ }
102
+ ```
103
+
104
+ ### HOTP
105
+
106
+ ```typescript
107
+ Hotp.generate(
108
+ secret: Buffer,
109
+ counter: number,
110
+ options?: Partial<HotpOptions>
111
+ ): string
112
+
113
+ Hotp.validate(
114
+ token: string,
115
+ secret: Buffer,
116
+ counter: number,
117
+ window: number = 0,
118
+ options?: Partial<HotpOptions>
119
+ ): boolean
120
+
121
+ // default values for option parameters that are omitted
122
+ static readonly DEFAULTS: HotpOptions = {
123
+ algorithm: HashAlgorithm.SHA1,
124
+ digits: 6,
125
+ }
126
+ ```
127
+
128
+ ### TOTP
129
+
130
+ ```typescript
131
+ Totp.generate(
132
+ secret: Buffer,
133
+ time: number = Date.now(),
134
+ options?: Partial<TotpOptions>
135
+ ): string
136
+
137
+ Totp.validate(
138
+ token: string,
139
+ secret: Buffer,
140
+ time: number = Date.now(),
141
+ window: number = 1,
142
+ options?: Partial<TotpOptions>
143
+ ): boolean
144
+
145
+ // remaining time until next token (seconds, fractional)
146
+ Totp.remaining(
147
+ period: number = 30,
148
+ time: number = Date.now()
149
+ ): number
150
+
151
+ // default values for option parameters that are omitted
152
+ static readonly DEFAULTS: TotpOptions = {
153
+ algorithm: HashAlgorithm.SHA1,
154
+ digits: 6,
155
+ period: 30,
156
+ }
157
+ ```
158
+
159
+ ### URL
160
+
161
+ ```typescript
162
+ // HOTP
163
+ Url.getHotpUrl(
164
+ issuer: string, // usually a domain or application name
165
+ label: string, // usually the username or email of the user
166
+ secret: Buffer,
167
+ counter: number,
168
+ options?: Partial<HotpOptions>
169
+ ): string
170
+
171
+ // TOTP
172
+ Url.getTotpUrl(
173
+ issuer: string, // usually a domain or application name
174
+ label: string, // usually the username or email of the user
175
+ secret: Buffer,
176
+ options?: Partial<TotpOptions>
177
+ ): string
178
+ ```
179
+
180
+ ## TODO
181
+
182
+ Probably a good idea to add some sanity checks in the future, e.g. for nonsensical stuff like negative periods, windows or missing mandatory parameters.
@@ -0,0 +1,35 @@
1
+ import { Buffer } from 'node:buffer';
2
+ declare namespace Otp {
3
+ const enum HashAlgorithm {
4
+ SHA1 = "sha1",
5
+ SHA256 = "sha256",
6
+ SHA384 = "sha384",
7
+ SHA512 = "sha512"
8
+ }
9
+ type HotpOptions = {
10
+ algorithm: HashAlgorithm;
11
+ digits: number;
12
+ };
13
+ type TotpOptions = {
14
+ algorithm: HashAlgorithm;
15
+ digits: number;
16
+ period: number;
17
+ };
18
+ class Hotp {
19
+ static readonly DEFAULTS: HotpOptions;
20
+ static generate(secret: Buffer, counter: number, options?: Partial<HotpOptions>): string;
21
+ static validate(token: string, secret: Buffer, counter: number, window?: number, options?: Partial<HotpOptions>): boolean;
22
+ }
23
+ class Totp {
24
+ static readonly DEFAULTS: TotpOptions;
25
+ static generate(secret: Buffer, time: number, options?: Partial<TotpOptions>): string;
26
+ static validate(token: string, secret: Buffer, time?: number, window?: number, options?: Partial<TotpOptions>): boolean;
27
+ static remaining(period?: number, time?: number): number;
28
+ static timeToCounter(time: number, period: number): number;
29
+ }
30
+ class Url {
31
+ static getHotpUrl(issuer: string, label: string, secret: Buffer, counter: number, options?: Partial<HotpOptions>): string;
32
+ static getTotpUrl(issuer: string, label: string, secret: Buffer, options?: Partial<TotpOptions>): string;
33
+ }
34
+ }
35
+ export default Otp;
package/dist/index.js ADDED
@@ -0,0 +1,143 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+ import { Buffer } from 'node:buffer';
3
+ var Otp;
4
+ (function (Otp) {
5
+ let HashAlgorithm;
6
+ (function (HashAlgorithm) {
7
+ HashAlgorithm["SHA1"] = "sha1";
8
+ HashAlgorithm["SHA256"] = "sha256";
9
+ HashAlgorithm["SHA384"] = "sha384";
10
+ HashAlgorithm["SHA512"] = "sha512";
11
+ })(HashAlgorithm = Otp.HashAlgorithm || (Otp.HashAlgorithm = {}));
12
+ class Hotp {
13
+ static generate(secret, counter, options) {
14
+ const opts = Object.assign(Object.assign({}, this.DEFAULTS), options);
15
+ const bytes = Buffer.alloc(8);
16
+ bytes.writeBigUInt64BE(BigInt(counter));
17
+ const hmac = createHmac(opts.algorithm, secret);
18
+ hmac.update(bytes);
19
+ const hash = hmac.digest();
20
+ const offset = hash[hash.length - 1] & 0xf;
21
+ const token = ((hash[offset] & 0x7f) << 24 >>> 0)
22
+ | hash[offset + 1] << 16
23
+ | hash[offset + 2] << 8
24
+ | hash[offset + 3];
25
+ return (token % (Math.pow(10, opts.digits))).toString().padStart(opts.digits, '0');
26
+ }
27
+ static validate(token, secret, counter, window = 0, options) {
28
+ const opts = Object.assign(Object.assign({}, this.DEFAULTS), options);
29
+ if (token.length != opts.digits)
30
+ return false;
31
+ const compare = (counter) => {
32
+ const generated = Hotp.generate(secret, counter, opts);
33
+ const token_buffer = Buffer.from(token);
34
+ const generated_buffer = Buffer.from(generated);
35
+ if (token_buffer.length != generated_buffer.length)
36
+ return false;
37
+ return timingSafeEqual(token_buffer, generated_buffer);
38
+ };
39
+ if (compare(counter))
40
+ return true;
41
+ for (let i = 1; i <= window; i++) {
42
+ if (compare(counter + i))
43
+ return true;
44
+ if (compare(counter - i))
45
+ return true;
46
+ }
47
+ return false;
48
+ }
49
+ }
50
+ Hotp.DEFAULTS = {
51
+ algorithm: HashAlgorithm.SHA1,
52
+ digits: 6,
53
+ };
54
+ Otp.Hotp = Hotp;
55
+ class Totp {
56
+ static generate(secret, time, options) {
57
+ const opts = Object.assign(Object.assign({}, this.DEFAULTS), options);
58
+ const counter = Totp.timeToCounter(time, opts.period);
59
+ return Hotp.generate(secret, counter, opts);
60
+ }
61
+ static validate(token, secret, time = Date.now(), window = 1, options) {
62
+ const opts = Object.assign(Object.assign({}, this.DEFAULTS), options);
63
+ const counter = Totp.timeToCounter(time, opts.period);
64
+ return Hotp.validate(token, secret, counter, window, opts);
65
+ }
66
+ static remaining(period = 30, time = Date.now()) {
67
+ return period - (time / 1000) % period;
68
+ }
69
+ static timeToCounter(time, period) {
70
+ return Math.floor((time / 1000) / period);
71
+ }
72
+ }
73
+ Totp.DEFAULTS = {
74
+ algorithm: HashAlgorithm.SHA1,
75
+ digits: 6,
76
+ period: 30,
77
+ };
78
+ Otp.Totp = Totp;
79
+ class Base32 {
80
+ static encode(buffer) {
81
+ let bits = 0;
82
+ let value = 0;
83
+ let output = '';
84
+ for (let i = 0; i < buffer.length; i++) {
85
+ value = (value << 8) | buffer[i];
86
+ bits += 8;
87
+ while (bits >= 5) {
88
+ output += this.ALPHABET[(value >>> (bits - 5)) & 31];
89
+ bits -= 5;
90
+ }
91
+ }
92
+ if (bits > 0) {
93
+ output += this.ALPHABET[(value << (5 - bits)) & 31];
94
+ }
95
+ return output;
96
+ }
97
+ static decode(str) {
98
+ const cleaned = str.toUpperCase().trim().replace(/[^A-Z2-7]/g, '');
99
+ const buffer = Buffer.alloc(Math.floor((cleaned.length * 5) / 8));
100
+ let bits = 0;
101
+ let value = 0;
102
+ let index = 0;
103
+ for (let i = 0; i < cleaned.length; i++) {
104
+ const val = this.ALPHABET.indexOf(cleaned[i]);
105
+ if (val === -1)
106
+ throw new Error('Invalid character in Base32 string');
107
+ value = (value << 5) | val;
108
+ bits += 5;
109
+ if (bits >= 8) {
110
+ buffer[index++] = (value >>> (bits - 8)) & 255;
111
+ bits -= 8;
112
+ }
113
+ }
114
+ return buffer;
115
+ }
116
+ }
117
+ Base32.ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
118
+ class Url {
119
+ static getHotpUrl(issuer, label, secret, counter, options) {
120
+ const opts = Object.assign(Object.assign({}, Hotp.DEFAULTS), options);
121
+ const url = new URL(`otpauth://hotp/${issuer}:${label}`);
122
+ url.searchParams.set('secret', Base32.encode(secret));
123
+ url.searchParams.set('algorithm', opts.algorithm.toUpperCase());
124
+ url.searchParams.set('digits', opts.digits.toString());
125
+ url.searchParams.set('counter', counter.toString());
126
+ url.searchParams.set('issuer', issuer);
127
+ return url.toString();
128
+ }
129
+ static getTotpUrl(issuer, label, secret, options) {
130
+ const opts = Object.assign(Object.assign({}, Totp.DEFAULTS), options);
131
+ const url = new URL(`otpauth://totp/${issuer}:${label}`);
132
+ url.searchParams.set('secret', Base32.encode(secret));
133
+ url.searchParams.set('algorithm', opts.algorithm.toUpperCase());
134
+ url.searchParams.set('digits', opts.digits.toString());
135
+ url.searchParams.set('period', opts.period.toString());
136
+ url.searchParams.set('issuer', issuer);
137
+ return url.toString();
138
+ }
139
+ }
140
+ Otp.Url = Url;
141
+ })(Otp || (Otp = {}));
142
+ export default Otp;
143
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"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"}
package/index.ts ADDED
@@ -0,0 +1,169 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto'
2
+ import { Buffer } from 'node:buffer'
3
+
4
+ namespace Otp {
5
+ export const enum HashAlgorithm {
6
+ SHA1 = 'sha1',
7
+ SHA256 = 'sha256',
8
+ SHA384 = 'sha384',
9
+ SHA512 = 'sha512',
10
+ }
11
+
12
+ export type HotpOptions = {
13
+ algorithm: HashAlgorithm
14
+ digits: number
15
+ }
16
+
17
+ export type TotpOptions = {
18
+ algorithm: HashAlgorithm
19
+ digits: number
20
+ period: number
21
+ }
22
+
23
+ export class Hotp {
24
+ static readonly DEFAULTS: HotpOptions = {
25
+ algorithm: HashAlgorithm.SHA1,
26
+ digits: 6,
27
+ }
28
+
29
+ static generate(secret: Buffer, counter: number, options?: Partial<HotpOptions>): string {
30
+ const opts = { ...this.DEFAULTS, ...options }
31
+ const bytes = Buffer.alloc(8)
32
+ bytes.writeBigUInt64BE(BigInt(counter))
33
+
34
+ const hmac = createHmac(opts.algorithm, secret)
35
+ hmac.update(bytes)
36
+ const hash = hmac.digest()
37
+
38
+ const offset = hash[hash.length - 1] & 0xf
39
+ const token = ((hash[offset] & 0x7f) << 24 >>> 0)
40
+ | hash[offset + 1] << 16
41
+ | hash[offset + 2] << 8
42
+ | hash[offset + 3]
43
+
44
+ return (token % (10 ** opts.digits)).toString().padStart(opts.digits, '0')
45
+ }
46
+
47
+ static validate(token: string, secret: Buffer, counter: number, window: number = 0, options?: Partial<HotpOptions>): boolean {
48
+ const opts = { ...this.DEFAULTS, ...options }
49
+ if (token.length != opts.digits) return false
50
+ const compare = (counter: number) => {
51
+ const generated = Hotp.generate(secret, counter, opts)
52
+ const token_buffer = Buffer.from(token)
53
+ const generated_buffer = Buffer.from(generated)
54
+ if (token_buffer.length != generated_buffer.length) return false
55
+ return timingSafeEqual(token_buffer, generated_buffer)
56
+ }
57
+ if (compare(counter)) return true
58
+ for (let i = 1; i <= window; i++) {
59
+ if (compare(counter + i)) return true
60
+ if (compare(counter - i)) return true
61
+ }
62
+ return false
63
+ }
64
+ }
65
+
66
+ export class Totp {
67
+ static readonly DEFAULTS: TotpOptions = {
68
+ algorithm: HashAlgorithm.SHA1,
69
+ digits: 6,
70
+ period: 30,
71
+ }
72
+
73
+ static generate(secret: Buffer, time: number, options?: Partial<TotpOptions>): string {
74
+ const opts = { ...this.DEFAULTS, ...options }
75
+ const counter = Totp.timeToCounter(time, opts.period)
76
+ return Hotp.generate(secret, counter, opts)
77
+ }
78
+
79
+ static validate(token: string, secret: Buffer, time: number = Date.now(), window: number = 1, options?: Partial<TotpOptions>): boolean {
80
+ const opts = { ...this.DEFAULTS, ...options }
81
+ const counter = Totp.timeToCounter(time, opts.period)
82
+ return Hotp.validate(token, secret, counter, window, opts)
83
+ }
84
+
85
+ static remaining(period: number = 30, time: number = Date.now()) {
86
+ return period - (time / 1000) % period
87
+ }
88
+
89
+ static timeToCounter(time: number, period: number): number {
90
+ return Math.floor((time / 1000) / period)
91
+ }
92
+ }
93
+
94
+ class Base32 {
95
+ private static readonly ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
96
+
97
+ public static encode(buffer: Buffer): string {
98
+ let bits = 0
99
+ let value = 0
100
+ let output = ''
101
+
102
+ for (let i = 0; i < buffer.length; i++) {
103
+ value = (value << 8) | buffer[i]
104
+ bits += 8
105
+
106
+ while (bits >= 5) {
107
+ output += this.ALPHABET[(value >>> (bits - 5)) & 31]
108
+ bits -= 5
109
+ }
110
+ }
111
+
112
+ if (bits > 0) {
113
+ output += this.ALPHABET[(value << (5 - bits)) & 31]
114
+ }
115
+
116
+ return output
117
+ }
118
+
119
+ public static decode(str: string): Buffer {
120
+ const cleaned = str.toUpperCase().trim().replace(/[^A-Z2-7]/g, '');
121
+ const buffer = Buffer.alloc(Math.floor((cleaned.length * 5) / 8))
122
+
123
+ let bits = 0
124
+ let value = 0
125
+ let index = 0
126
+
127
+ for (let i = 0; i < cleaned.length; i++) {
128
+ const val = this.ALPHABET.indexOf(cleaned[i])
129
+ if (val === -1) throw new Error('Invalid character in Base32 string')
130
+
131
+ value = (value << 5) | val
132
+ bits += 5
133
+
134
+ if (bits >= 8) {
135
+ buffer[index++] = (value >>> (bits - 8)) & 255
136
+ bits -= 8
137
+ }
138
+ }
139
+
140
+ return buffer
141
+ }
142
+ }
143
+
144
+ export class Url {
145
+ public static getHotpUrl(issuer: string, label: string, secret: Buffer, counter: number, options?: Partial<HotpOptions>): string {
146
+ const opts = { ...Hotp.DEFAULTS, ...options }
147
+ const url = new URL(`otpauth://hotp/${issuer}:${label}`)
148
+ url.searchParams.set('secret', Base32.encode(secret))
149
+ url.searchParams.set('algorithm', opts.algorithm.toUpperCase())
150
+ url.searchParams.set('digits', opts.digits.toString())
151
+ url.searchParams.set('counter', counter.toString())
152
+ url.searchParams.set('issuer', issuer)
153
+ return url.toString()
154
+ }
155
+
156
+ public static getTotpUrl(issuer: string, label: string, secret: Buffer, options?: Partial<TotpOptions>): string {
157
+ const opts = { ...Totp.DEFAULTS, ...options }
158
+ const url = new URL(`otpauth://totp/${issuer}:${label}`)
159
+ url.searchParams.set('secret', Base32.encode(secret))
160
+ url.searchParams.set('algorithm', opts.algorithm.toUpperCase())
161
+ url.searchParams.set('digits', opts.digits.toString())
162
+ url.searchParams.set('period', opts.period.toString())
163
+ url.searchParams.set('issuer', issuer)
164
+ return url.toString()
165
+ }
166
+ }
167
+ }
168
+
169
+ export default Otp
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@punikonta/node-otp",
3
+ "version": "0.0.1",
4
+ "description": "Simple and dependency free OTP implementation (HOTP, TOTP) for Node.js written in TypeScript. Passes RFC test vectors and works with most common authenticator apps.",
5
+ "keywords": [
6
+ "otp",
7
+ "hotp",
8
+ "totp",
9
+ "2fa",
10
+ "mfa",
11
+ "security",
12
+ "typescript",
13
+ "node",
14
+ "rfc4226",
15
+ "rfc6238",
16
+ "authenticator",
17
+ "authy",
18
+ "google-authenticator",
19
+ "microsoft-authenticator",
20
+ "proton-authenticator",
21
+ "bitwarden-authenticator"
22
+ ],
23
+ "main": "dist/index.js",
24
+ "types": "dist/index.d.ts",
25
+ "type": "module",
26
+ "license": "MIT",
27
+ "scripts": {
28
+ "build": "npx tsc",
29
+ "test": "node --test test.mjs"
30
+ },
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "https://github.com/punikonta/node-otp"
34
+ },
35
+ "bugs": {
36
+ "url": "https://github.com/punikonta/node-otp/issues",
37
+ "email": "punikonta@protonmail.com"
38
+ },
39
+ "author": {
40
+ "name": "Marcel Jovic",
41
+ "email": "punikonta@protonmail.com",
42
+ "url": "https://punikonta.de"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^25.5.2",
46
+ "typescript": "^6.0.2"
47
+ }
48
+ }
package/test.mjs ADDED
@@ -0,0 +1,66 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert'
3
+
4
+ import Otp from './dist/index.js'
5
+
6
+ const rfc = {
7
+ hotp: [
8
+ // "RFC 4226 - Appendix D - HOTP Algorithm: Test Values"
9
+ // https://datatracker.ietf.org/doc/html/rfc4226
10
+ { secret: '12345678901234567890', count: 0, expected: '755224', },
11
+ { secret: '12345678901234567890', count: 1, expected: '287082', },
12
+ { secret: '12345678901234567890', count: 2, expected: '359152', },
13
+ { secret: '12345678901234567890', count: 3, expected: '969429', },
14
+ { secret: '12345678901234567890', count: 4, expected: '338314', },
15
+ { secret: '12345678901234567890', count: 5, expected: '254676', },
16
+ { secret: '12345678901234567890', count: 6, expected: '287922', },
17
+ { secret: '12345678901234567890', count: 7, expected: '162583', },
18
+ { secret: '12345678901234567890', count: 8, expected: '399871', },
19
+ { secret: '12345678901234567890', count: 9, expected: '520489', },
20
+ ],
21
+ totp: [
22
+ // "RFC 6238 - Appendix B - Test Vectors"
23
+ // https://datatracker.ietf.org/doc/html/rfc6238
24
+ { time: 59, expected: '94287082', algorithm: Otp.HashAlgorithm.SHA1, secret: '12345678901234567890', },
25
+ { time: 59, expected: '46119246', algorithm: Otp.HashAlgorithm.SHA256, secret: '12345678901234567890123456789012', },
26
+ { time: 59, expected: '90693936', algorithm: Otp.HashAlgorithm.SHA512, secret: '1234567890123456789012345678901234567890123456789012345678901234', },
27
+ { time: 1111111109, expected: '07081804', algorithm: Otp.HashAlgorithm.SHA1, secret: '12345678901234567890', },
28
+ { time: 1111111109, expected: '68084774', algorithm: Otp.HashAlgorithm.SHA256, secret: '12345678901234567890123456789012', },
29
+ { time: 1111111109, expected: '25091201', algorithm: Otp.HashAlgorithm.SHA512, secret: '1234567890123456789012345678901234567890123456789012345678901234', },
30
+ { time: 1111111111, expected: '14050471', algorithm: Otp.HashAlgorithm.SHA1, secret: '12345678901234567890', },
31
+ { time: 1111111111, expected: '67062674', algorithm: Otp.HashAlgorithm.SHA256, secret: '12345678901234567890123456789012', },
32
+ { time: 1111111111, expected: '99943326', algorithm: Otp.HashAlgorithm.SHA512, secret: '1234567890123456789012345678901234567890123456789012345678901234', },
33
+ { time: 1234567890, expected: '89005924', algorithm: Otp.HashAlgorithm.SHA1, secret: '12345678901234567890', },
34
+ { time: 1234567890, expected: '91819424', algorithm: Otp.HashAlgorithm.SHA256, secret: '12345678901234567890123456789012', },
35
+ { time: 1234567890, expected: '93441116', algorithm: Otp.HashAlgorithm.SHA512, secret: '1234567890123456789012345678901234567890123456789012345678901234', },
36
+ { time: 2000000000, expected: '69279037', algorithm: Otp.HashAlgorithm.SHA1, secret: '12345678901234567890', },
37
+ { time: 2000000000, expected: '90698825', algorithm: Otp.HashAlgorithm.SHA256, secret: '12345678901234567890123456789012', },
38
+ { time: 2000000000, expected: '38618901', algorithm: Otp.HashAlgorithm.SHA512, secret: '1234567890123456789012345678901234567890123456789012345678901234', },
39
+ { time: 20000000000, expected: '65353130', algorithm: Otp.HashAlgorithm.SHA1, secret: '12345678901234567890', },
40
+ { time: 20000000000, expected: '77737706', algorithm: Otp.HashAlgorithm.SHA256, secret: '12345678901234567890123456789012', },
41
+ { time: 20000000000, expected: '47863826', algorithm: Otp.HashAlgorithm.SHA512, secret: '1234567890123456789012345678901234567890123456789012345678901234', },
42
+ ],
43
+ }
44
+
45
+ test('hotp rfc4226 test values', () => {
46
+ for (const value of rfc.hotp) {
47
+ test(`test value count ${value.count}`, () => {
48
+ const key = Buffer.from(value.secret, 'utf8')
49
+ const actual = Otp.Hotp.generate(key, value.count)
50
+
51
+ assert.strictEqual(value.expected, actual)
52
+ })
53
+ }
54
+ })
55
+
56
+ test('totp rfc6238 test values', () => {
57
+ for (const value of rfc.totp) {
58
+ test(`test value, time: ${value.time}, algorithm: ${value.algorithm}`, () => {
59
+ const key = Buffer.from(value.secret, 'utf8')
60
+ const time = value.time * 1000 // test values are given in seconds, but we expect milliseconds
61
+ const actual = Otp.Totp.generate(key, time, { algorithm: value.algorithm, digits: value.expected.length })
62
+
63
+ assert.strictEqual(value.expected, actual)
64
+ })
65
+ }
66
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es6",
4
+ "module": "preserve",
5
+ "declaration": true,
6
+ "noImplicitAny": true,
7
+ "removeComments": true,
8
+ "preserveConstEnums": true,
9
+ "isolatedModules": true,
10
+ "sourceMap": true,
11
+ "outDir": "dist",
12
+ "types": [
13
+ "node"
14
+ ]
15
+ },
16
+ "files": [
17
+ "./index.ts"
18
+ ]
19
+ }