@tsctupper/otpkit 0.0.0-stage → 1.1.3
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/CHANGELOG.md +85 -0
- package/LICENSE +21 -0
- package/README.md +128 -2
- package/dist/base32.d.ts +13 -0
- package/dist/base32.d.ts.map +1 -0
- package/dist/base32.js +66 -0
- package/dist/base32.js.map +1 -0
- package/dist/hotp.d.ts +15 -0
- package/dist/hotp.d.ts.map +1 -0
- package/dist/hotp.js +34 -0
- package/dist/hotp.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/totp.d.ts +34 -0
- package/dist/totp.d.ts.map +1 -0
- package/dist/totp.js +64 -0
- package/dist/totp.js.map +1 -0
- package/dist/uri.d.ts +22 -0
- package/dist/uri.d.ts.map +1 -0
- package/dist/uri.js +51 -0
- package/dist/uri.js.map +1 -0
- package/package.json +67 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres
|
|
5
|
+
to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [1.1.3] — 2026-11-03
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- The release workflow now authenticates to npm with trusted publishing (OIDC)
|
|
14
|
+
instead of a stored token. No library changes.
|
|
15
|
+
|
|
16
|
+
## [1.1.2] — 2026-11-03
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- The package is now published as `@tsctupper/otpkit`. npm rejected the
|
|
21
|
+
unscoped name `otpkit` as too similar to an existing package. Install with
|
|
22
|
+
`npm install @tsctupper/otpkit`. No library changes. Versions 1.1.0 and 1.1.1
|
|
23
|
+
were tagged on GitHub but never published to npm.
|
|
24
|
+
|
|
25
|
+
## [1.1.1] — 2026-11-03
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- The release workflow now stages the npm publish (`npm stage publish`) instead
|
|
30
|
+
of publishing directly, because npm is retiring two-factor-bypass tokens. A
|
|
31
|
+
maintainer approves the staged release on npmjs.com. No library changes.
|
|
32
|
+
|
|
33
|
+
## [1.1.0] — 2026-11-03
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- `matchTotp` returns the matched time-step counter (or `null`), making replay
|
|
38
|
+
protection straightforward (RFC 6238 §5.2). `verifyTotp` is now built on it.
|
|
39
|
+
- `MAX_WINDOW` export. A `window` above 100 now throws `RangeError`, bounding
|
|
40
|
+
the HMAC work that untrusted input can request.
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- `verifyTotp` no longer throws when the drift window reaches past the epoch
|
|
45
|
+
(e.g. verifying within the first period with `window: 1`); pre-epoch steps
|
|
46
|
+
are skipped and verification returns `false` as expected.
|
|
47
|
+
- `period` and `now` are validated up front — `period: 0` now throws a clear
|
|
48
|
+
`RangeError` instead of a confusing BigInt conversion error.
|
|
49
|
+
- `digits` is restricted to 6–8 per RFC 4226 §5.3; dynamic truncation yields at
|
|
50
|
+
most 31 bits, so 9–10 digit codes could never cover their full range.
|
|
51
|
+
- `otpauthURL` percent-encodes issuer and label separately with a literal `:`
|
|
52
|
+
separator, matching the Key URI Format's canonical examples.
|
|
53
|
+
- Empty secrets and non-digit tokens are rejected explicitly.
|
|
54
|
+
- `otpauthURL` encodes spaces as `%20` instead of `+` in query values (issuer),
|
|
55
|
+
and validates `label`, `digits` and `period`.
|
|
56
|
+
- `hotp` rejects counters above 2^64 − 1 instead of silently wrapping.
|
|
57
|
+
- `base32Decode` rejects `=` anywhere but the end, and trims trailing padding in
|
|
58
|
+
linear time (a regex here was quadratic on long runs of `=`).
|
|
59
|
+
|
|
60
|
+
### Changed
|
|
61
|
+
|
|
62
|
+
- CI now tests every supported Node line: 18, 20, 22, and 24.
|
|
63
|
+
- Strict type-checked ESLint, Prettier, `noUncheckedIndexedAccess`, and a
|
|
64
|
+
type-checked test suite split per module, with 100% line and branch coverage.
|
|
65
|
+
- CI gains a lint/format/coverage job and a packed-install smoke test.
|
|
66
|
+
- Added `SECURITY.md`, `CONTRIBUTING.md`, issue and PR templates, Dependabot,
|
|
67
|
+
CodeQL, and an npm release workflow with provenance.
|
|
68
|
+
|
|
69
|
+
## [1.0.0] — 2026-07-16
|
|
70
|
+
|
|
71
|
+
### Added
|
|
72
|
+
|
|
73
|
+
- `hotp` — RFC 4226 HMAC-based OTP (SHA-1/256/512, configurable digits).
|
|
74
|
+
- `totp` / `verifyTotp` — RFC 6238 time-based OTP with constant-time,
|
|
75
|
+
drift-window verification.
|
|
76
|
+
- `generateSecret` and `otpauthURL` for enrollment (QR / manual key).
|
|
77
|
+
- `base32Encode` / `base32Decode` — RFC 4648 Base32, tolerant decoding.
|
|
78
|
+
- Test suite pinned to the RFC 4226, 6238, and 4648 official vectors.
|
|
79
|
+
|
|
80
|
+
[Unreleased]: https://github.com/tsctupper/otpkit/compare/v1.1.3...HEAD
|
|
81
|
+
[1.1.3]: https://github.com/tsctupper/otpkit/compare/v1.1.2...v1.1.3
|
|
82
|
+
[1.1.2]: https://github.com/tsctupper/otpkit/compare/v1.1.1...v1.1.2
|
|
83
|
+
[1.1.1]: https://github.com/tsctupper/otpkit/compare/v1.1.0...v1.1.1
|
|
84
|
+
[1.1.0]: https://github.com/tsctupper/otpkit/compare/v1.0.0...v1.1.0
|
|
85
|
+
[1.0.0]: https://github.com/tsctupper/otpkit/releases/tag/v1.0.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tyler Tupper
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,129 @@
|
|
|
1
|
-
#
|
|
1
|
+
# otpkit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Zero-dependency, RFC-verified HOTP & TOTP (2FA) for Node.js and TypeScript.**
|
|
4
|
+
|
|
5
|
+
[](https://github.com/tsctupper/otpkit/actions/workflows/ci.yml)
|
|
6
|
+
[](./LICENSE)
|
|
7
|
+

|
|
8
|
+

|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
The one-time-password codes behind Google Authenticator, Authy, and every "enter your 6-digit code" screen — implemented straight from the RFCs, with **no runtime dependencies** and a test suite that asserts **byte-for-byte compliance with the official specification test vectors**.
|
|
12
|
+
|
|
13
|
+
- HOTP (RFC 4226) and TOTP (RFC 6238), SHA-1 / SHA-256 / SHA-512
|
|
14
|
+
- Base32 (RFC 4648) secrets — the format authenticator apps use
|
|
15
|
+
- `otpauth://` provisioning URIs for QR-code enrollment
|
|
16
|
+
- Constant-time verification with a bounded clock-drift window, plus `matchTotp` for replay protection
|
|
17
|
+
- Zero dependencies, ESM, first-class TypeScript types
|
|
18
|
+
- Verified against the RFC 4226 / 6238 / 4648 test vectors in CI, with 100% line and branch coverage
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
npm install @tsctupper/otpkit
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Node >= 18. Ships ESM + `.d.ts` types.
|
|
27
|
+
|
|
28
|
+
## Quick start
|
|
29
|
+
|
|
30
|
+
### Enroll a user
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { generateSecret, otpauthURL, base32Encode } from "@tsctupper/otpkit";
|
|
34
|
+
|
|
35
|
+
const secret = generateSecret(); // 20 random bytes (160 bits)
|
|
36
|
+
const uri = otpauthURL(secret, {
|
|
37
|
+
label: "alice@example.com",
|
|
38
|
+
issuer: "Acme",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Render `uri` as a QR code for the user to scan, and store `secret` (encrypted).
|
|
42
|
+
// The manual-entry key is the Base32 form:
|
|
43
|
+
const manualKey = base32Encode(secret);
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Verify a code
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { verifyTotp, base32Decode } from "@tsctupper/otpkit";
|
|
50
|
+
|
|
51
|
+
const secret = base32Decode(storedBase32Secret);
|
|
52
|
+
const ok = verifyTotp(userSubmittedCode, secret); // ±1 step of drift by default
|
|
53
|
+
|
|
54
|
+
if (!ok) throw new Error("Invalid code");
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Reject replayed codes
|
|
58
|
+
|
|
59
|
+
A valid code stays valid for its whole window, so an attacker who sees one can reuse it. `matchTotp` tells you _which_ time step matched, so you can refuse anything you have already accepted:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { matchTotp } from "@tsctupper/otpkit";
|
|
63
|
+
|
|
64
|
+
const step = matchTotp(userSubmittedCode, secret);
|
|
65
|
+
if (step === null || step <= user.lastTotpStep) throw new Error("Invalid code");
|
|
66
|
+
|
|
67
|
+
user.lastTotpStep = step; // persist per user (e.g. in your database)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Generate a code (client side, tests, or debugging)
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
import { totp, hotp } from "@tsctupper/otpkit";
|
|
74
|
+
|
|
75
|
+
totp(secret); // current 6-digit TOTP
|
|
76
|
+
totp(secret, { digits: 8, algorithm: "SHA256", period: 60 });
|
|
77
|
+
hotp(secret, 42); // counter-based (RFC 4226)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## API
|
|
81
|
+
|
|
82
|
+
| Function | Purpose |
|
|
83
|
+
| ---------------------------------- | --------------------------------------------------------------------------------------------------------- |
|
|
84
|
+
| `hotp(secret, counter, opts?)` | RFC 4226 counter-based OTP. |
|
|
85
|
+
| `totp(secret, opts?)` | RFC 6238 time-based OTP for now (or an injected `now`). |
|
|
86
|
+
| `verifyTotp(token, secret, opts?)` | Constant-time check across a drift `window` (default ±1, max `MAX_WINDOW` = 100). Returns `boolean`. |
|
|
87
|
+
| `matchTotp(token, secret, opts?)` | Like `verifyTotp`, but returns the matched time-step (`bigint`) or `null` — use it for replay protection. |
|
|
88
|
+
| `generateSecret(bytes?)` | CSPRNG secret, default 20 bytes / 160 bits. |
|
|
89
|
+
| `otpauthURL(secret, params)` | `otpauth://totp/...` enrollment URI. |
|
|
90
|
+
| `base32Encode` / `base32Decode` | RFC 4648 Base32, tolerant decode. |
|
|
91
|
+
|
|
92
|
+
`secret` is always **raw bytes** (`Uint8Array`) — decode Base32 text first. Options: `digits` (6–8, default 6), `algorithm` (`"SHA1"` default), `period` (30s), `window`, `now`, `epoch`. Full types are bundled.
|
|
93
|
+
|
|
94
|
+
Invalid configuration (out-of-range digits, non-positive period, a `window` outside 0–100, an empty secret or label) throws a `RangeError`; a malformed or wrong token never throws — `verifyTotp` just returns `false`.
|
|
95
|
+
|
|
96
|
+
## Standards & verification
|
|
97
|
+
|
|
98
|
+
Correctness isn't asserted by hand — the test suite runs the specifications' own vectors:
|
|
99
|
+
|
|
100
|
+
| Standard | Covers | Vectors checked |
|
|
101
|
+
| -------------------------------------------------- | ------ | ------------------------------------ |
|
|
102
|
+
| [RFC 4226](https://www.rfc-editor.org/rfc/rfc4226) | HOTP | Appendix D (counters 0–9) |
|
|
103
|
+
| [RFC 6238](https://www.rfc-editor.org/rfc/rfc6238) | TOTP | Appendix B (SHA-1/256/512, 8 digits) |
|
|
104
|
+
| [RFC 4648](https://www.rfc-editor.org/rfc/rfc4648) | Base32 | §10 canonical vectors |
|
|
105
|
+
|
|
106
|
+
Run them yourself: `npm test`.
|
|
107
|
+
|
|
108
|
+
## Security notes
|
|
109
|
+
|
|
110
|
+
- **Verification is constant-time.** Every candidate step is compared with `crypto.timingSafeEqual` and the loop never short-circuits, so neither the result nor the matching step leaks through timing.
|
|
111
|
+
- **Secrets are ≥ 160 bits** by default, per RFC 4226 §4. Store them encrypted at rest; never log them.
|
|
112
|
+
- **Keep the drift `window` small** (±1 is usually right). A larger window is more forgiving of clock skew but widens the guess surface. It is capped at 100 steps so untrusted input can't force unbounded HMAC work.
|
|
113
|
+
- This library generates and verifies codes. **Replay protection** and **rate-limiting failed attempts** are the caller's responsibility — see `matchTotp` above. A 6-digit code has only a million possibilities, so always throttle.
|
|
114
|
+
- Found a vulnerability? See [SECURITY.md](./SECURITY.md).
|
|
115
|
+
|
|
116
|
+
## Development
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
npm ci
|
|
120
|
+
npm run check # typecheck, lint, format check, tests
|
|
121
|
+
npm run coverage # line/branch coverage (held at 100%)
|
|
122
|
+
npm run build # emit dist/ (ESM + .d.ts)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
See [CONTRIBUTING.md](./CONTRIBUTING.md) and the [CHANGELOG](./CHANGELOG.md).
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
[MIT](./LICENSE) © Tyler Tupper
|
package/dist/base32.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decode an RFC 4648 Base32 string to raw bytes.
|
|
3
|
+
*
|
|
4
|
+
* Lowercase letters, whitespace, and trailing `=` padding are accepted. Anything
|
|
5
|
+
* else — including `=` anywhere but the end — throws, so a typo in a stored
|
|
6
|
+
* secret fails loudly instead of silently producing the wrong key.
|
|
7
|
+
*
|
|
8
|
+
* @throws {Error} on a character outside the Base32 alphabet or misplaced padding.
|
|
9
|
+
*/
|
|
10
|
+
export declare function base32Decode(input: string): Uint8Array;
|
|
11
|
+
/** Encode raw bytes to a canonical, `=`-padded RFC 4648 Base32 string. */
|
|
12
|
+
export declare function base32Encode(bytes: Uint8Array): string;
|
|
13
|
+
//# sourceMappingURL=base32.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"base32.d.ts","sourceRoot":"","sources":["../src/base32.ts"],"names":[],"mappings":"AAeA;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,UAAU,CA2BtD;AAED,0EAA0E;AAC1E,wBAAgB,YAAY,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,CAgBtD"}
|
package/dist/base32.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// RFC 4648 §6 Base32 — the encoding authenticator apps use for shared secrets
|
|
2
|
+
// (e.g. the string you scan or type into Google Authenticator). Decoding is
|
|
3
|
+
// tolerant of lowercase, whitespace, and missing `=` padding, because real-world
|
|
4
|
+
// secrets are copied by hand; encoding emits canonical padded output.
|
|
5
|
+
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
6
|
+
// Flat lookup table: 255 marks "not in the alphabet". A typed array (rather than
|
|
7
|
+
// an object map) avoids prototype-key surprises and keeps lookups branch-light.
|
|
8
|
+
const DECODE_TABLE = new Uint8Array(128).fill(255);
|
|
9
|
+
for (let i = 0; i < ALPHABET.length; i++) {
|
|
10
|
+
DECODE_TABLE[ALPHABET.charCodeAt(i)] = i;
|
|
11
|
+
DECODE_TABLE[ALPHABET.toLowerCase().charCodeAt(i)] = i;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Decode an RFC 4648 Base32 string to raw bytes.
|
|
15
|
+
*
|
|
16
|
+
* Lowercase letters, whitespace, and trailing `=` padding are accepted. Anything
|
|
17
|
+
* else — including `=` anywhere but the end — throws, so a typo in a stored
|
|
18
|
+
* secret fails loudly instead of silently producing the wrong key.
|
|
19
|
+
*
|
|
20
|
+
* @throws {Error} on a character outside the Base32 alphabet or misplaced padding.
|
|
21
|
+
*/
|
|
22
|
+
export function base32Decode(input) {
|
|
23
|
+
const stripped = input.replace(/\s/g, "");
|
|
24
|
+
// Trim trailing padding with a loop, not /=+$/: that regex is quadratic on
|
|
25
|
+
// long runs of "=" followed by another character (polynomial ReDoS).
|
|
26
|
+
let end = stripped.length;
|
|
27
|
+
while (end > 0 && stripped.charCodeAt(end - 1) === 61 /* "=" */)
|
|
28
|
+
end--;
|
|
29
|
+
const clean = stripped.slice(0, end);
|
|
30
|
+
const out = new Uint8Array(Math.floor((clean.length * 5) / 8));
|
|
31
|
+
let bits = 0;
|
|
32
|
+
let value = 0;
|
|
33
|
+
let written = 0;
|
|
34
|
+
for (let i = 0; i < clean.length; i++) {
|
|
35
|
+
const code = clean.charCodeAt(i);
|
|
36
|
+
const idx = code < 128 ? DECODE_TABLE[code] : 255;
|
|
37
|
+
if (idx === undefined || idx === 255) {
|
|
38
|
+
throw new Error(`Invalid Base32 character: ${JSON.stringify(clean[i])}`);
|
|
39
|
+
}
|
|
40
|
+
value = ((value << 5) | idx) & 0xffff; // only the low 12 bits are ever needed
|
|
41
|
+
bits += 5;
|
|
42
|
+
if (bits >= 8) {
|
|
43
|
+
bits -= 8;
|
|
44
|
+
out[written++] = (value >>> bits) & 0xff;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
/** Encode raw bytes to a canonical, `=`-padded RFC 4648 Base32 string. */
|
|
50
|
+
export function base32Encode(bytes) {
|
|
51
|
+
let out = "";
|
|
52
|
+
let bits = 0;
|
|
53
|
+
let value = 0;
|
|
54
|
+
for (const byte of bytes) {
|
|
55
|
+
value = ((value << 8) | byte) & 0xffff;
|
|
56
|
+
bits += 8;
|
|
57
|
+
while (bits >= 5) {
|
|
58
|
+
bits -= 5;
|
|
59
|
+
out += ALPHABET.charAt((value >>> bits) & 0x1f);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (bits > 0)
|
|
63
|
+
out += ALPHABET.charAt((value << (5 - bits)) & 0x1f);
|
|
64
|
+
return out.padEnd(Math.ceil(out.length / 8) * 8, "=");
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=base32.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"base32.js","sourceRoot":"","sources":["../src/base32.ts"],"names":[],"mappings":"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"}
|
package/dist/hotp.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type OtpAlgorithm = "SHA1" | "SHA256" | "SHA512";
|
|
2
|
+
export interface HotpOptions {
|
|
3
|
+
/** Digits in the generated code, 6–8 per RFC 4226 §5.3 (default 6). */
|
|
4
|
+
digits?: number;
|
|
5
|
+
/** HMAC hash — default "SHA1", the value authenticator apps assume. */
|
|
6
|
+
algorithm?: OtpAlgorithm;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* RFC 4226 HMAC-based One-Time Password.
|
|
10
|
+
*
|
|
11
|
+
* @param secret Raw shared-secret bytes (Base32-decode first if you hold text).
|
|
12
|
+
* @param counter Moving factor; `number` or `bigint`, encoded 8-byte big-endian.
|
|
13
|
+
*/
|
|
14
|
+
export declare function hotp(secret: Uint8Array, counter: number | bigint, options?: HotpOptions): string;
|
|
15
|
+
//# sourceMappingURL=hotp.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hotp.d.ts","sourceRoot":"","sources":["../src/hotp.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAExD,MAAM,WAAW,WAAW;IAC1B,uEAAuE;IACvE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,uEAAuE;IACvE,SAAS,CAAC,EAAE,YAAY,CAAC;CAC1B;AAED;;;;;GAKG;AACH,wBAAgB,IAAI,CAAC,MAAM,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,OAAO,GAAE,WAAgB,GAAG,MAAM,CA4BpG"}
|
package/dist/hotp.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* RFC 4226 HMAC-based One-Time Password.
|
|
4
|
+
*
|
|
5
|
+
* @param secret Raw shared-secret bytes (Base32-decode first if you hold text).
|
|
6
|
+
* @param counter Moving factor; `number` or `bigint`, encoded 8-byte big-endian.
|
|
7
|
+
*/
|
|
8
|
+
export function hotp(secret, counter, options = {}) {
|
|
9
|
+
const digits = options.digits ?? 6;
|
|
10
|
+
const algorithm = options.algorithm ?? "SHA1";
|
|
11
|
+
// Dynamic truncation yields at most 31 bits, so codes longer than 8 digits
|
|
12
|
+
// could never span their full range — RFC 4226 §5.3 defines 6–8.
|
|
13
|
+
if (!Number.isInteger(digits) || digits < 6 || digits > 8) {
|
|
14
|
+
throw new RangeError("digits must be an integer between 6 and 8");
|
|
15
|
+
}
|
|
16
|
+
if (secret.length === 0)
|
|
17
|
+
throw new RangeError("secret must not be empty");
|
|
18
|
+
// 8-byte big-endian counter — RFC 4226 §5.1.
|
|
19
|
+
let c = BigInt(counter);
|
|
20
|
+
if (c < 0n || c > 0xffffffffffffffffn) {
|
|
21
|
+
throw new RangeError("counter must be between 0 and 2^64 - 1");
|
|
22
|
+
}
|
|
23
|
+
const counterBytes = new Uint8Array(8);
|
|
24
|
+
for (let i = 7; i >= 0; i--) {
|
|
25
|
+
counterBytes[i] = Number(c & 0xffn);
|
|
26
|
+
c >>= 8n;
|
|
27
|
+
}
|
|
28
|
+
const mac = createHmac(algorithm.toLowerCase(), Buffer.from(secret)).update(counterBytes).digest();
|
|
29
|
+
// Dynamic truncation — RFC 4226 §5.3.
|
|
30
|
+
const offset = mac.readUInt8(mac.length - 1) & 0x0f;
|
|
31
|
+
const binCode = mac.readUInt32BE(offset) & 0x7fffffff;
|
|
32
|
+
return (binCode % 10 ** digits).toString().padStart(digits, "0");
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=hotp.js.map
|
package/dist/hotp.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hotp.js","sourceRoot":"","sources":["../src/hotp.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAWzC;;;;;GAKG;AACH,MAAM,UAAU,IAAI,CAAC,MAAkB,EAAE,OAAwB,EAAE,UAAuB,EAAE;IAC1F,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,IAAI,CAAC,CAAC;IACnC,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,IAAI,MAAM,CAAC;IAC9C,2EAA2E;IAC3E,iEAAiE;IACjE,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,MAAM,CAAC,IAAI,MAAM,GAAG,CAAC,IAAI,MAAM,GAAG,CAAC,EAAE,CAAC;QAC1D,MAAM,IAAI,UAAU,CAAC,2CAA2C,CAAC,CAAC;IACpE,CAAC;IACD,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,MAAM,IAAI,UAAU,CAAC,0BAA0B,CAAC,CAAC;IAE1E,6CAA6C;IAC7C,IAAI,CAAC,GAAG,MAAM,CAAC,OAAO,CAAC,CAAC;IACxB,IAAI,CAAC,GAAG,EAAE,IAAI,CAAC,GAAG,mBAAmB,EAAE,CAAC;QACtC,MAAM,IAAI,UAAU,CAAC,wCAAwC,CAAC,CAAC;IACjE,CAAC;IACD,MAAM,YAAY,GAAG,IAAI,UAAU,CAAC,CAAC,CAAC,CAAC;IACvC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC;QAC5B,YAAY,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,CAAC,GAAG,KAAK,CAAC,CAAC;QACpC,CAAC,KAAK,EAAE,CAAC;IACX,CAAC;IAED,MAAM,GAAG,GAAG,UAAU,CAAC,SAAS,CAAC,WAAW,EAAE,EAAE,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,YAAY,CAAC,CAAC,MAAM,EAAE,CAAC;IAEnG,sCAAsC;IACtC,MAAM,MAAM,GAAG,GAAG,CAAC,SAAS,CAAC,GAAG,CAAC,MAAM,GAAG,CAAC,CAAC,GAAG,IAAI,CAAC;IACpD,MAAM,OAAO,GAAG,GAAG,CAAC,YAAY,CAAC,MAAM,CAAC,GAAG,UAAU,CAAC;IAEtD,OAAO,CAAC,OAAO,GAAG,EAAE,IAAI,MAAM,CAAC,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;AACnE,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { base32Decode, base32Encode } from "./base32.js";
|
|
2
|
+
export { hotp, type HotpOptions, type OtpAlgorithm } from "./hotp.js";
|
|
3
|
+
export { MAX_WINDOW, matchTotp, totp, verifyTotp, type TotpOptions, type VerifyOptions } from "./totp.js";
|
|
4
|
+
export { generateSecret, otpauthURL, type OtpauthParams } from "./uri.js";
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AACzD,OAAO,EAAE,IAAI,EAAE,KAAK,WAAW,EAAE,KAAK,YAAY,EAAE,MAAM,WAAW,CAAC;AACtE,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,IAAI,EAAE,UAAU,EAAE,KAAK,WAAW,EAAE,KAAK,aAAa,EAAE,MAAM,WAAW,CAAC;AAC1G,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,KAAK,aAAa,EAAE,MAAM,UAAU,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AACzD,OAAO,EAAE,IAAI,EAAuC,MAAM,WAAW,CAAC;AACtE,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,IAAI,EAAE,UAAU,EAAwC,MAAM,WAAW,CAAC;AAC1G,OAAO,EAAE,cAAc,EAAE,UAAU,EAAsB,MAAM,UAAU,CAAC"}
|
package/dist/totp.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type HotpOptions } from "./hotp.js";
|
|
2
|
+
export interface TotpOptions extends HotpOptions {
|
|
3
|
+
/** Time step in seconds (default 30). */
|
|
4
|
+
period?: number;
|
|
5
|
+
/** Unix epoch offset T0 in seconds (default 0). */
|
|
6
|
+
epoch?: number;
|
|
7
|
+
/** Unix time in milliseconds (default `Date.now()`). Inject for deterministic tests. */
|
|
8
|
+
now?: number;
|
|
9
|
+
}
|
|
10
|
+
/** RFC 6238 time-based OTP for the current (or supplied) moment. */
|
|
11
|
+
export declare function totp(secret: Uint8Array, options?: TotpOptions): string;
|
|
12
|
+
/** Largest accepted drift window. Each step costs one HMAC, so this bounds CPU work. */
|
|
13
|
+
export declare const MAX_WINDOW = 100;
|
|
14
|
+
export interface VerifyOptions extends TotpOptions {
|
|
15
|
+
/** Clock-drift steps accepted on either side (default 1 = ±one period, max {@link MAX_WINDOW}). */
|
|
16
|
+
window?: number;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Check a token against a drift window in constant time and report *which*
|
|
20
|
+
* time step matched.
|
|
21
|
+
*
|
|
22
|
+
* Returns the matching RFC 6238 time-step counter, or `null` if nothing in the
|
|
23
|
+
* window matched. Persist the counter per user and reject any token whose
|
|
24
|
+
* counter is `<=` the last accepted one — that is replay protection
|
|
25
|
+
* (RFC 6238 §5.2).
|
|
26
|
+
*
|
|
27
|
+
* Every candidate step is checked (no early return) and each comparison uses
|
|
28
|
+
* `timingSafeEqual`, so neither *whether* nor *which* step matched leaks
|
|
29
|
+
* through timing. Malformed tokens return `null`; invalid configuration throws.
|
|
30
|
+
*/
|
|
31
|
+
export declare function matchTotp(token: string, secret: Uint8Array, options?: VerifyOptions): bigint | null;
|
|
32
|
+
/** Verify a token against a drift window in constant time. See {@link matchTotp}. */
|
|
33
|
+
export declare function verifyTotp(token: string, secret: Uint8Array, options?: VerifyOptions): boolean;
|
|
34
|
+
//# sourceMappingURL=totp.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"totp.d.ts","sourceRoot":"","sources":["../src/totp.ts"],"names":[],"mappings":"AACA,OAAO,EAAQ,KAAK,WAAW,EAAE,MAAM,WAAW,CAAC;AAEnD,MAAM,WAAW,WAAY,SAAQ,WAAW;IAC9C,yCAAyC;IACzC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,mDAAmD;IACnD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,wFAAwF;IACxF,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAgBD,oEAAoE;AACpE,wBAAgB,IAAI,CAAC,MAAM,EAAE,UAAU,EAAE,OAAO,GAAE,WAAgB,GAAG,MAAM,CAE1E;AAED,wFAAwF;AACxF,eAAO,MAAM,UAAU,MAAM,CAAC;AAE9B,MAAM,WAAW,aAAc,SAAQ,WAAW;IAChD,mGAAmG;IACnG,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,OAAO,GAAE,aAAkB,GAAG,MAAM,GAAG,IAAI,CAoBvG;AAED,qFAAqF;AACrF,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,OAAO,GAAE,aAAkB,GAAG,OAAO,CAElG"}
|
package/dist/totp.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { hotp } from "./hotp.js";
|
|
3
|
+
/** Time-step index for a moment — RFC 6238 §4.2. */
|
|
4
|
+
function counterFor(options) {
|
|
5
|
+
const period = options.period ?? 30;
|
|
6
|
+
const epoch = options.epoch ?? 0;
|
|
7
|
+
const nowMs = options.now ?? Date.now();
|
|
8
|
+
if (!Number.isFinite(period) || period <= 0) {
|
|
9
|
+
throw new RangeError("period must be a positive number of seconds");
|
|
10
|
+
}
|
|
11
|
+
if (!Number.isFinite(nowMs))
|
|
12
|
+
throw new RangeError("now must be a finite Unix time in milliseconds");
|
|
13
|
+
const seconds = Math.floor(nowMs / 1000) - epoch;
|
|
14
|
+
if (seconds < 0)
|
|
15
|
+
throw new RangeError("now is before the epoch (T0)");
|
|
16
|
+
return BigInt(Math.floor(seconds / period));
|
|
17
|
+
}
|
|
18
|
+
/** RFC 6238 time-based OTP for the current (or supplied) moment. */
|
|
19
|
+
export function totp(secret, options = {}) {
|
|
20
|
+
return hotp(secret, counterFor(options), options);
|
|
21
|
+
}
|
|
22
|
+
/** Largest accepted drift window. Each step costs one HMAC, so this bounds CPU work. */
|
|
23
|
+
export const MAX_WINDOW = 100;
|
|
24
|
+
/**
|
|
25
|
+
* Check a token against a drift window in constant time and report *which*
|
|
26
|
+
* time step matched.
|
|
27
|
+
*
|
|
28
|
+
* Returns the matching RFC 6238 time-step counter, or `null` if nothing in the
|
|
29
|
+
* window matched. Persist the counter per user and reject any token whose
|
|
30
|
+
* counter is `<=` the last accepted one — that is replay protection
|
|
31
|
+
* (RFC 6238 §5.2).
|
|
32
|
+
*
|
|
33
|
+
* Every candidate step is checked (no early return) and each comparison uses
|
|
34
|
+
* `timingSafeEqual`, so neither *whether* nor *which* step matched leaks
|
|
35
|
+
* through timing. Malformed tokens return `null`; invalid configuration throws.
|
|
36
|
+
*/
|
|
37
|
+
export function matchTotp(token, secret, options = {}) {
|
|
38
|
+
const window = options.window ?? 1;
|
|
39
|
+
if (!Number.isInteger(window) || window < 0 || window > MAX_WINDOW) {
|
|
40
|
+
throw new RangeError(`window must be an integer between 0 and ${MAX_WINDOW}`);
|
|
41
|
+
}
|
|
42
|
+
const digits = options.digits ?? 6;
|
|
43
|
+
if (token.length !== digits || !/^[0-9]+$/.test(token))
|
|
44
|
+
return null;
|
|
45
|
+
const expected = Buffer.from(token, "ascii");
|
|
46
|
+
const center = counterFor(options);
|
|
47
|
+
let matched = null;
|
|
48
|
+
for (let e = -window; e <= window; e++) {
|
|
49
|
+
const step = center + BigInt(e);
|
|
50
|
+
// Steps before the epoch don't exist — skip rather than throw, so a
|
|
51
|
+
// drift window that reaches past T0 can never crash verification.
|
|
52
|
+
if (step < 0n)
|
|
53
|
+
continue;
|
|
54
|
+
const candidate = Buffer.from(hotp(secret, step, options), "ascii");
|
|
55
|
+
if (timingSafeEqual(candidate, expected))
|
|
56
|
+
matched = step;
|
|
57
|
+
}
|
|
58
|
+
return matched;
|
|
59
|
+
}
|
|
60
|
+
/** Verify a token against a drift window in constant time. See {@link matchTotp}. */
|
|
61
|
+
export function verifyTotp(token, secret, options = {}) {
|
|
62
|
+
return matchTotp(token, secret, options) !== null;
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=totp.js.map
|
package/dist/totp.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"totp.js","sourceRoot":"","sources":["../src/totp.ts"],"names":[],"mappings":"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"}
|
package/dist/uri.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { OtpAlgorithm } from "./hotp.js";
|
|
2
|
+
/**
|
|
3
|
+
* Cryptographically-random secret. Default 20 bytes (160 bits) — the minimum
|
|
4
|
+
* RFC 4226 §4 R6 recommends.
|
|
5
|
+
*/
|
|
6
|
+
export declare function generateSecret(bytes?: number): Uint8Array;
|
|
7
|
+
export interface OtpauthParams {
|
|
8
|
+
/** Account label shown in the authenticator, e.g. the user's email. */
|
|
9
|
+
label: string;
|
|
10
|
+
/** Issuer / app name. */
|
|
11
|
+
issuer?: string;
|
|
12
|
+
digits?: number;
|
|
13
|
+
period?: number;
|
|
14
|
+
algorithm?: OtpAlgorithm;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Build the `otpauth://totp/...` provisioning URI authenticator apps import
|
|
18
|
+
* (usually as a QR code), per the de-facto Key URI Format. The secret is
|
|
19
|
+
* Base32-encoded and unpadded, as those apps expect.
|
|
20
|
+
*/
|
|
21
|
+
export declare function otpauthURL(secret: Uint8Array, params: OtpauthParams): string;
|
|
22
|
+
//# sourceMappingURL=uri.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"uri.d.ts","sourceRoot":"","sources":["../src/uri.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AAE9C;;;GAGG;AACH,wBAAgB,cAAc,CAAC,KAAK,SAAK,GAAG,UAAU,CAGrD;AAED,MAAM,WAAW,aAAa;IAC5B,uEAAuE;IACvE,KAAK,EAAE,MAAM,CAAC;IACd,yBAAyB;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,YAAY,CAAC;CAC1B;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,aAAa,GAAG,MAAM,CAyB5E"}
|
package/dist/uri.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { base32Encode } from "./base32.js";
|
|
3
|
+
/**
|
|
4
|
+
* Cryptographically-random secret. Default 20 bytes (160 bits) — the minimum
|
|
5
|
+
* RFC 4226 §4 R6 recommends.
|
|
6
|
+
*/
|
|
7
|
+
export function generateSecret(bytes = 20) {
|
|
8
|
+
if (!Number.isInteger(bytes) || bytes < 16)
|
|
9
|
+
throw new RangeError("secret must be at least 16 bytes");
|
|
10
|
+
return new Uint8Array(randomBytes(bytes));
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Build the `otpauth://totp/...` provisioning URI authenticator apps import
|
|
14
|
+
* (usually as a QR code), per the de-facto Key URI Format. The secret is
|
|
15
|
+
* Base32-encoded and unpadded, as those apps expect.
|
|
16
|
+
*/
|
|
17
|
+
export function otpauthURL(secret, params) {
|
|
18
|
+
const { label, issuer, digits = 6, period = 30, algorithm = "SHA1" } = params;
|
|
19
|
+
// Key URI Format: path is `issuer:label`, each component percent-encoded
|
|
20
|
+
// separately with the `:` separator left literal, as in the spec's examples.
|
|
21
|
+
if (label.length === 0)
|
|
22
|
+
throw new RangeError("label must not be empty");
|
|
23
|
+
if (!Number.isInteger(digits) || digits < 6 || digits > 8) {
|
|
24
|
+
throw new RangeError("digits must be an integer between 6 and 8");
|
|
25
|
+
}
|
|
26
|
+
if (!Number.isInteger(period) || period <= 0) {
|
|
27
|
+
throw new RangeError("period must be a positive integer number of seconds");
|
|
28
|
+
}
|
|
29
|
+
const path = issuer
|
|
30
|
+
? `${encodeURIComponent(issuer)}:${encodeURIComponent(label)}`
|
|
31
|
+
: encodeURIComponent(label);
|
|
32
|
+
// Built by hand rather than URLSearchParams: that encodes spaces as `+`,
|
|
33
|
+
// which several authenticator apps show literally (e.g. "Acme+Corp").
|
|
34
|
+
const pairs = [
|
|
35
|
+
["secret", unpaddedBase32(secret)],
|
|
36
|
+
["algorithm", algorithm],
|
|
37
|
+
["digits", String(digits)],
|
|
38
|
+
["period", String(period)],
|
|
39
|
+
];
|
|
40
|
+
if (issuer)
|
|
41
|
+
pairs.push(["issuer", issuer]);
|
|
42
|
+
const query = pairs.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&");
|
|
43
|
+
return `otpauth://totp/${path}?${query}`;
|
|
44
|
+
}
|
|
45
|
+
/** Base32 without `=` padding, as authenticator apps expect in the URI. */
|
|
46
|
+
function unpaddedBase32(secret) {
|
|
47
|
+
const encoded = base32Encode(secret);
|
|
48
|
+
const pad = encoded.indexOf("=");
|
|
49
|
+
return pad === -1 ? encoded : encoded.slice(0, pad);
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=uri.js.map
|
package/dist/uri.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"uri.js","sourceRoot":"","sources":["../src/uri.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC1C,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAG3C;;;GAGG;AACH,MAAM,UAAU,cAAc,CAAC,KAAK,GAAG,EAAE;IACvC,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,KAAK,GAAG,EAAE;QAAE,MAAM,IAAI,UAAU,CAAC,kCAAkC,CAAC,CAAC;IACrG,OAAO,IAAI,UAAU,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,CAAC;AAC5C,CAAC;AAYD;;;;GAIG;AACH,MAAM,UAAU,UAAU,CAAC,MAAkB,EAAE,MAAqB;IAClE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,GAAG,CAAC,EAAE,MAAM,GAAG,EAAE,EAAE,SAAS,GAAG,MAAM,EAAE,GAAG,MAAM,CAAC;IAC9E,yEAAyE;IACzE,6EAA6E;IAC7E,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;QAAE,MAAM,IAAI,UAAU,CAAC,yBAAyB,CAAC,CAAC;IACxE,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,MAAM,CAAC,IAAI,MAAM,GAAG,CAAC,IAAI,MAAM,GAAG,CAAC,EAAE,CAAC;QAC1D,MAAM,IAAI,UAAU,CAAC,2CAA2C,CAAC,CAAC;IACpE,CAAC;IACD,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,MAAM,CAAC,IAAI,MAAM,IAAI,CAAC,EAAE,CAAC;QAC7C,MAAM,IAAI,UAAU,CAAC,qDAAqD,CAAC,CAAC;IAC9E,CAAC;IACD,MAAM,IAAI,GAAG,MAAM;QACjB,CAAC,CAAC,GAAG,kBAAkB,CAAC,MAAM,CAAC,IAAI,kBAAkB,CAAC,KAAK,CAAC,EAAE;QAC9D,CAAC,CAAC,kBAAkB,CAAC,KAAK,CAAC,CAAC;IAC9B,yEAAyE;IACzE,sEAAsE;IACtE,MAAM,KAAK,GAAuB;QAChC,CAAC,QAAQ,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;QAClC,CAAC,WAAW,EAAE,SAAS,CAAC;QACxB,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC;QAC1B,CAAC,QAAQ,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC;KAC3B,CAAC;IACF,IAAI,MAAM;QAAE,KAAK,CAAC,IAAI,CAAC,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,CAAC;IAC3C,MAAM,KAAK,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,IAAI,kBAAkB,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAC/E,OAAO,kBAAkB,IAAI,IAAI,KAAK,EAAE,CAAC;AAC3C,CAAC;AAED,2EAA2E;AAC3E,SAAS,cAAc,CAAC,MAAkB;IACxC,MAAM,OAAO,GAAG,YAAY,CAAC,MAAM,CAAC,CAAC;IACrC,MAAM,GAAG,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;IACjC,OAAO,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;AACtD,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,69 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tsctupper/otpkit",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.1.3",
|
|
4
|
+
"description": "Zero-dependency, RFC-verified HOTP & TOTP (2FA / authenticator) codes for Node.js and TypeScript.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE",
|
|
17
|
+
"CHANGELOG.md"
|
|
18
|
+
],
|
|
19
|
+
"sideEffects": false,
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc -p tsconfig.build.json",
|
|
22
|
+
"typecheck": "tsc --noEmit",
|
|
23
|
+
"lint": "eslint .",
|
|
24
|
+
"format": "prettier --write .",
|
|
25
|
+
"format:check": "prettier --check .",
|
|
26
|
+
"test": "tsx --test test/*.test.ts",
|
|
27
|
+
"coverage": "tsx --test --experimental-test-coverage --test-coverage-include='src/**' test/*.test.ts",
|
|
28
|
+
"check": "npm run typecheck && npm run lint && npm run format:check && npm test",
|
|
29
|
+
"clean": "rm -rf dist coverage",
|
|
30
|
+
"prepublishOnly": "npm run clean && npm run check && npm run build"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"totp",
|
|
34
|
+
"hotp",
|
|
35
|
+
"otp",
|
|
36
|
+
"2fa",
|
|
37
|
+
"mfa",
|
|
38
|
+
"authenticator",
|
|
39
|
+
"google-authenticator",
|
|
40
|
+
"two-factor",
|
|
41
|
+
"rfc6238",
|
|
42
|
+
"rfc4226",
|
|
43
|
+
"rfc4648",
|
|
44
|
+
"zero-dependency",
|
|
45
|
+
"typescript"
|
|
46
|
+
],
|
|
47
|
+
"author": "Tyler Tupper (https://github.com/tsctupper)",
|
|
48
|
+
"license": "MIT",
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=18"
|
|
51
|
+
},
|
|
52
|
+
"repository": {
|
|
53
|
+
"type": "git",
|
|
54
|
+
"url": "git+https://github.com/tsctupper/otpkit.git"
|
|
55
|
+
},
|
|
56
|
+
"bugs": {
|
|
57
|
+
"url": "https://github.com/tsctupper/otpkit/issues"
|
|
58
|
+
},
|
|
59
|
+
"homepage": "https://github.com/tsctupper/otpkit#readme",
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@eslint/js": "^9.39.5",
|
|
62
|
+
"@types/node": "^22.10.2",
|
|
63
|
+
"eslint": "^9.39.5",
|
|
64
|
+
"prettier": "^3.9.10",
|
|
65
|
+
"tsx": "^4.19.2",
|
|
66
|
+
"typescript": "^5.7.2",
|
|
67
|
+
"typescript-eslint": "^8.72.1"
|
|
68
|
+
}
|
|
69
|
+
}
|