totp-auth-service 0.1.0
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 +21 -0
- package/README.md +255 -0
- package/dist/crypto.cjs +145 -0
- package/dist/crypto.cjs.map +1 -0
- package/dist/crypto.d.cts +45 -0
- package/dist/crypto.d.ts +45 -0
- package/dist/crypto.js +139 -0
- package/dist/crypto.js.map +1 -0
- package/dist/index.cjs +379 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +114 -0
- package/dist/index.d.ts +114 -0
- package/dist/index.js +368 -0
- package/dist/index.js.map +1 -0
- package/dist/storage-xBzobyb-.d.cts +44 -0
- package/dist/storage-xBzobyb-.d.ts +44 -0
- package/dist/testing.cjs +70 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +25 -0
- package/dist/testing.d.ts +25 -0
- package/dist/testing.js +68 -0
- package/dist/testing.js.map +1 -0
- package/dist/totp-algorithm-CUpdtI9d.d.cts +8 -0
- package/dist/totp-algorithm-CUpdtI9d.d.ts +8 -0
- package/package.json +81 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ameya Aklekar
|
|
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
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# totp-auth
|
|
2
|
+
|
|
3
|
+
Self-hostable TOTP MFA library for Node.js. Handles enrollment, verification, and single-use recovery codes — not full authentication (no sessions or JWTs).
|
|
4
|
+
|
|
5
|
+
Your app owns primary login, sessions, and JWTs. This library only handles the **MFA step**: enroll, confirm, verify, revoke, and backup codes.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Requirements
|
|
10
|
+
|
|
11
|
+
- **Node.js 18+**
|
|
12
|
+
- A **database** (or other store) where you implement `StorageAdapter`
|
|
13
|
+
- **Server-side only** — do not bundle `TOTPService` or secrets into a browser/React client
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install totp-auth-service
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The unscoped name `totp-auth` is already registered on npm; this package publishes as **`totp-auth-service`** (same repo).
|
|
24
|
+
|
|
25
|
+
### Develop from a git clone
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
git clone https://github.com/ameyaaklekar/totp-auth-service.git
|
|
29
|
+
cd totp-auth-service
|
|
30
|
+
npm install
|
|
31
|
+
npm run build
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Link into another project while developing:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npm link
|
|
38
|
+
# in your app directory:
|
|
39
|
+
npm link totp-auth-service
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Setup guide
|
|
45
|
+
|
|
46
|
+
### 1. Create a server-side `TOTPService` instance
|
|
47
|
+
|
|
48
|
+
Instantiate once per process (or per request with a shared adapter) in your Node API layer — Express, Fastify, Next.js Route Handlers, etc.
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
// lib/totp.ts
|
|
52
|
+
import { TOTPService, TotpAlgorithm } from 'totp-auth-service'
|
|
53
|
+
import { myStorageAdapter } from './totp-storage.js'
|
|
54
|
+
|
|
55
|
+
export const totp = new TOTPService({
|
|
56
|
+
storage: myStorageAdapter,
|
|
57
|
+
issuer: 'MyApp', // shown in authenticator apps
|
|
58
|
+
digits: 6, // 6 or 8 (default: 6)
|
|
59
|
+
period: 30, // seconds (default: 30)
|
|
60
|
+
algorithm: TotpAlgorithm.SHA1,
|
|
61
|
+
window: 1, // ±1 period clock drift (default: 1)
|
|
62
|
+
recoveryCodeCount: 10, // backup codes on confirm (default: 10)
|
|
63
|
+
})
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### 2. Implement `StorageAdapter`
|
|
67
|
+
|
|
68
|
+
The library does not ship database adapters. Implement the interface for Postgres, Redis, MongoDB, Supabase, etc.
|
|
69
|
+
|
|
70
|
+
**Tables (conceptual):**
|
|
71
|
+
|
|
72
|
+
| Table / collection | Fields |
|
|
73
|
+
|--------------------|--------|
|
|
74
|
+
| `totp_enrollments` | `userId`, `secret` (Base32), `status`, `createdAt`, `confirmedAt`, `revokedAt` |
|
|
75
|
+
| `totp_recovery_codes` | `userId`, `codeHash` (SHA-256 hex), `usedAt` |
|
|
76
|
+
|
|
77
|
+
**Contracts:**
|
|
78
|
+
|
|
79
|
+
- `saveEnrollment` — upsert by `userId`
|
|
80
|
+
- `getEnrollment` — return `null` if missing (do not throw)
|
|
81
|
+
- `getRecoveryCodes` — return `[]` if none
|
|
82
|
+
- Only **hashed** recovery codes are stored; plaintext is returned once from `confirm()` / `regenerateRecoveryCodes()`
|
|
83
|
+
- Serialize concurrent writes per `userId` in your adapter (DB locks or atomic updates)
|
|
84
|
+
|
|
85
|
+
```typescript
|
|
86
|
+
import type { StorageAdapter, TOTPEnrollment, RecoveryCode } from 'totp-auth-service'
|
|
87
|
+
|
|
88
|
+
export const myStorageAdapter: StorageAdapter = {
|
|
89
|
+
async saveEnrollment(enrollment) { /* upsert */ },
|
|
90
|
+
async getEnrollment(userId) { /* ... */ },
|
|
91
|
+
async updateEnrollment(userId, patch) { /* ... */ },
|
|
92
|
+
async deleteEnrollment(userId) { /* ... */ },
|
|
93
|
+
async saveRecoveryCodes(codes) { /* replace batch for user */ },
|
|
94
|
+
async getRecoveryCodes(userId) { /* ... */ },
|
|
95
|
+
async markRecoveryCodeUsed(userId, codeHash) { /* ... */ },
|
|
96
|
+
async deleteRecoveryCodes(userId) { /* ... */ },
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Use `MemoryAdapter` from `totp-auth-service/testing` only in tests — not in production.
|
|
101
|
+
|
|
102
|
+
### 3. Expose HTTP routes (your app)
|
|
103
|
+
|
|
104
|
+
The library has no built-in HTTP layer. Map methods to routes after the user is authenticated (session/JWT with `userId`).
|
|
105
|
+
|
|
106
|
+
| Route (example) | Method | `TOTPService` |
|
|
107
|
+
|-----------------|--------|----------------|
|
|
108
|
+
| `POST /api/mfa/enroll` | Start setup | `enroll(userId)` |
|
|
109
|
+
| `POST /api/mfa/confirm` | First code from app | `confirm(userId, code)` |
|
|
110
|
+
| `POST /api/mfa/verify` | Login MFA step | `verify(userId, code)` |
|
|
111
|
+
| `GET /api/mfa/status` | Settings UI | `getStatus(userId)` |
|
|
112
|
+
| `POST /api/mfa/revoke` | Disable 2FA | `revoke(userId)` |
|
|
113
|
+
| `POST /api/mfa/recovery/regenerate` | New backup codes | `regenerateRecoveryCodes(userId)` |
|
|
114
|
+
| `DELETE /api/user` (or similar) | Account deletion | `delete(userId)` |
|
|
115
|
+
|
|
116
|
+
**Example — enroll (Next.js Route Handler):**
|
|
117
|
+
|
|
118
|
+
```typescript
|
|
119
|
+
import { totp } from '@/lib/totp'
|
|
120
|
+
import { getSessionUserId } from '@/lib/auth'
|
|
121
|
+
|
|
122
|
+
export async function POST() {
|
|
123
|
+
const userId = await getSessionUserId()
|
|
124
|
+
const { otpAuthUri } = await totp.enroll(userId)
|
|
125
|
+
return Response.json({ otpAuthUri })
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
**Example — verify after password login:**
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
export async function POST(req: Request) {
|
|
133
|
+
const userId = await getPartialLoginUserId()
|
|
134
|
+
const { code } = await req.json()
|
|
135
|
+
const result = await totp.verify(userId, code)
|
|
136
|
+
if (!result.valid) {
|
|
137
|
+
return Response.json({ valid: false }, { status: 401 })
|
|
138
|
+
}
|
|
139
|
+
await issueSession(userId) // your auth — not part of totp-auth
|
|
140
|
+
return Response.json(result)
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Map thrown errors to HTTP status + JSON `{ code: 'ENROLLMENT_NOT_FOUND' }` using `TOTPErrorCode` from `totp-auth-service`.
|
|
145
|
+
|
|
146
|
+
### 4. React (or any frontend)
|
|
147
|
+
|
|
148
|
+
React talks to **your API**, not to `TOTPService` directly.
|
|
149
|
+
|
|
150
|
+
1. **Enroll** — `POST /api/mfa/enroll` → render QR from `otpAuthUri` (e.g. `react-qr-code`).
|
|
151
|
+
2. **Confirm** — user enters 6-digit code → `POST /api/mfa/confirm` → show `recoveryCodes` once; user must save them.
|
|
152
|
+
3. **Login MFA** — after password step, show code input → `POST /api/mfa/verify` → on success, complete session in your API.
|
|
153
|
+
|
|
154
|
+
Never return the shared secret to the client in production if you can avoid it; `otpAuthUri` alone is enough for QR setup.
|
|
155
|
+
|
|
156
|
+
```tsx
|
|
157
|
+
// Client — fetch only, no totp-auth import
|
|
158
|
+
const res = await fetch('/api/mfa/enroll', { method: 'POST' })
|
|
159
|
+
const { otpAuthUri } = await res.json()
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### 5. Enrollment state machine
|
|
163
|
+
|
|
164
|
+
```
|
|
165
|
+
[none | revoked] --enroll()--> pending --confirm()--> active --revoke()--> revoked
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
| Method | Allowed when |
|
|
169
|
+
|--------|----------------|
|
|
170
|
+
| `enroll()` | No row, or `revoked` |
|
|
171
|
+
| `confirm()` | `pending` |
|
|
172
|
+
| `verify()` | `active` |
|
|
173
|
+
| `revoke()` | `pending` or `active` |
|
|
174
|
+
| `regenerateRecoveryCodes()` | `active` |
|
|
175
|
+
| `delete()` | Always (no throw) |
|
|
176
|
+
|
|
177
|
+
Full API details: [Requirements.md](./Requirements.md).
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## Quick start (Node script / tests)
|
|
182
|
+
|
|
183
|
+
```typescript
|
|
184
|
+
import { TOTPService, EnrollmentStatus, TOTPErrorCode } from 'totp-auth-service'
|
|
185
|
+
import { generateCode } from 'totp-auth-service/crypto'
|
|
186
|
+
import { MemoryAdapter } from 'totp-auth-service/testing'
|
|
187
|
+
|
|
188
|
+
const totp = new TOTPService({
|
|
189
|
+
storage: new MemoryAdapter(),
|
|
190
|
+
issuer: 'MyApp',
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
const userId = 'user-123'
|
|
194
|
+
|
|
195
|
+
const { secret, otpAuthUri } = await totp.enroll(userId)
|
|
196
|
+
console.log(otpAuthUri) // otpauth://totp/...
|
|
197
|
+
|
|
198
|
+
const { recoveryCodes } = await totp.confirm(userId, generateCode(secret))
|
|
199
|
+
console.log(recoveryCodes) // save once; not recoverable later
|
|
200
|
+
|
|
201
|
+
const { valid } = await totp.verify(userId, generateCode(secret))
|
|
202
|
+
console.log(valid) // true
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
## Development (this repository)
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
npm install
|
|
211
|
+
npm run build # dist/ (CJS + ESM + .d.ts)
|
|
212
|
+
npm test # unit + service tests
|
|
213
|
+
npm run test:watch
|
|
214
|
+
npm run typecheck
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### Manual demo with QR code
|
|
218
|
+
|
|
219
|
+
Runs the full enroll → confirm → verify flow and prints a scannable QR (terminal + PNG):
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
npm run demo
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
- Terminal: ASCII QR from `otpauth://` URI
|
|
226
|
+
- File: `totp-qr.png` in the project root (open and scan with Google Authenticator, etc.)
|
|
227
|
+
- The script still uses `generateCode()` for confirm/verify so it finishes without typing a code; scan the QR only if you want to compare with a real app
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## Entry points
|
|
232
|
+
|
|
233
|
+
| Import | Purpose |
|
|
234
|
+
|--------|---------|
|
|
235
|
+
| `totp-auth-service` | `TOTPService`, `StorageAdapter`, errors, enums |
|
|
236
|
+
| `totp-auth-service/crypto` | `generateSecret`, `buildOtpAuthUri`, `generateCode`, `verifyCode` |
|
|
237
|
+
| `totp-auth-service/testing` | `MemoryAdapter` (tests only) |
|
|
238
|
+
|
|
239
|
+
**Public enums:** `EnrollmentStatus`, `TotpAlgorithm`, `TOTPErrorCode`.
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
## Publishing (maintainers)
|
|
244
|
+
|
|
245
|
+
1. Log in: `npm login`
|
|
246
|
+
2. Dry-run the tarball: `npm pack --dry-run`
|
|
247
|
+
3. Publish: `npm publish` (runs `prepublishOnly` → build + tests)
|
|
248
|
+
|
|
249
|
+
Or create a [GitHub Release](https://github.com/ameyaaklekar/totp-auth-service/releases) — the `publish-npm` workflow publishes the release tag version when `NPM_TOKEN` is set in repo secrets.
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
## License
|
|
254
|
+
|
|
255
|
+
MIT
|
package/dist/crypto.cjs
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
// src/crypto/secret.ts
|
|
6
|
+
|
|
7
|
+
// src/crypto/base32.ts
|
|
8
|
+
var ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
9
|
+
function encodeBase32(buffer) {
|
|
10
|
+
let bits = 0;
|
|
11
|
+
let value = 0;
|
|
12
|
+
let output = "";
|
|
13
|
+
for (const byte of buffer) {
|
|
14
|
+
value = value << 8 | byte;
|
|
15
|
+
bits += 8;
|
|
16
|
+
while (bits >= 5) {
|
|
17
|
+
output += ALPHABET[value >>> bits - 5 & 31];
|
|
18
|
+
bits -= 5;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (bits > 0) {
|
|
22
|
+
output += ALPHABET[value << 5 - bits & 31];
|
|
23
|
+
}
|
|
24
|
+
return output;
|
|
25
|
+
}
|
|
26
|
+
function decodeBase32(encoded) {
|
|
27
|
+
const normalized = encoded.replace(/=+$/, "").toUpperCase();
|
|
28
|
+
let bits = 0;
|
|
29
|
+
let value = 0;
|
|
30
|
+
const bytes = [];
|
|
31
|
+
for (const char of normalized) {
|
|
32
|
+
const index = ALPHABET.indexOf(char);
|
|
33
|
+
if (index === -1) {
|
|
34
|
+
throw new Error(`Invalid base32 character: ${char}`);
|
|
35
|
+
}
|
|
36
|
+
value = value << 5 | index;
|
|
37
|
+
bits += 5;
|
|
38
|
+
if (bits >= 8) {
|
|
39
|
+
bytes.push(value >>> bits - 8 & 255);
|
|
40
|
+
bits -= 8;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return Buffer.from(bytes);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// src/crypto/secret.ts
|
|
47
|
+
var DEFAULT_BYTE_LENGTH = 20;
|
|
48
|
+
function generateSecret(options) {
|
|
49
|
+
const byteLength = options?.length ?? DEFAULT_BYTE_LENGTH;
|
|
50
|
+
if (byteLength < 16) {
|
|
51
|
+
throw new Error("Secret length must be at least 16 bytes");
|
|
52
|
+
}
|
|
53
|
+
return encodeBase32(crypto.randomBytes(byteLength));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// src/enums/totp-algorithm.ts
|
|
57
|
+
var TotpAlgorithm = /* @__PURE__ */ ((TotpAlgorithm2) => {
|
|
58
|
+
TotpAlgorithm2["SHA1"] = "SHA1";
|
|
59
|
+
TotpAlgorithm2["SHA256"] = "SHA256";
|
|
60
|
+
TotpAlgorithm2["SHA512"] = "SHA512";
|
|
61
|
+
return TotpAlgorithm2;
|
|
62
|
+
})(TotpAlgorithm || {});
|
|
63
|
+
|
|
64
|
+
// src/crypto/uri.ts
|
|
65
|
+
function buildOtpAuthUri(options) {
|
|
66
|
+
const digits = options.digits ?? 6;
|
|
67
|
+
const period = options.period ?? 30;
|
|
68
|
+
const algorithm = options.algorithm ?? "SHA1" /* SHA1 */;
|
|
69
|
+
const label = encodeURIComponent(`${options.issuer}:${options.accountName}`);
|
|
70
|
+
const params = new URLSearchParams({
|
|
71
|
+
secret: options.secret,
|
|
72
|
+
issuer: options.issuer,
|
|
73
|
+
algorithm,
|
|
74
|
+
digits: String(digits),
|
|
75
|
+
period: String(period)
|
|
76
|
+
});
|
|
77
|
+
return `otpauth://totp/${label}?${params.toString()}`;
|
|
78
|
+
}
|
|
79
|
+
function algorithmToHashName(algorithm) {
|
|
80
|
+
switch (algorithm) {
|
|
81
|
+
case "SHA256" /* SHA256 */:
|
|
82
|
+
return "sha256";
|
|
83
|
+
case "SHA512" /* SHA512 */:
|
|
84
|
+
return "sha512";
|
|
85
|
+
default:
|
|
86
|
+
return "sha1";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function counterToBuffer(counter) {
|
|
90
|
+
const buf = Buffer.alloc(8);
|
|
91
|
+
let value = counter;
|
|
92
|
+
for (let i = 7; i >= 0; i--) {
|
|
93
|
+
buf[i] = value & 255;
|
|
94
|
+
value = Math.floor(value / 256);
|
|
95
|
+
}
|
|
96
|
+
return buf;
|
|
97
|
+
}
|
|
98
|
+
function hotp(secret, counter, digits, algorithm) {
|
|
99
|
+
const hmac = crypto.createHmac(algorithmToHashName(algorithm), secret);
|
|
100
|
+
hmac.update(counterToBuffer(counter));
|
|
101
|
+
const digest = hmac.digest();
|
|
102
|
+
const offset = digest[digest.length - 1] & 15;
|
|
103
|
+
const binary = (digest[offset] & 127) << 24 | (digest[offset + 1] & 255) << 16 | (digest[offset + 2] & 255) << 8 | digest[offset + 3] & 255;
|
|
104
|
+
const otp = binary % 10 ** digits;
|
|
105
|
+
return otp.toString().padStart(digits, "0");
|
|
106
|
+
}
|
|
107
|
+
function resolveTimestamp(timestamp) {
|
|
108
|
+
return timestamp ?? Date.now();
|
|
109
|
+
}
|
|
110
|
+
function generateCode(secret, options) {
|
|
111
|
+
const period = options?.period ?? 30;
|
|
112
|
+
const digits = options?.digits ?? 6;
|
|
113
|
+
const algorithm = options?.algorithm ?? "SHA1" /* SHA1 */;
|
|
114
|
+
const timestamp = resolveTimestamp(options?.timestamp);
|
|
115
|
+
const counter = Math.floor(timestamp / 1e3 / period);
|
|
116
|
+
const key = decodeBase32(secret);
|
|
117
|
+
return hotp(key, counter, digits, algorithm);
|
|
118
|
+
}
|
|
119
|
+
function verifyCode(secret, code, options) {
|
|
120
|
+
const period = options?.period ?? 30;
|
|
121
|
+
const digits = options?.digits ?? 6;
|
|
122
|
+
const window = options?.window ?? 0;
|
|
123
|
+
const algorithm = options?.algorithm ?? "SHA1" /* SHA1 */;
|
|
124
|
+
const timestamp = resolveTimestamp(options?.timestamp);
|
|
125
|
+
const counter = Math.floor(timestamp / 1e3 / period);
|
|
126
|
+
const key = decodeBase32(secret);
|
|
127
|
+
const normalized = code.replace(/\s/g, "");
|
|
128
|
+
if (!/^\d+$/.test(normalized) || normalized.length !== digits) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
for (let drift = -window; drift <= window; drift++) {
|
|
132
|
+
if (hotp(key, counter + drift, digits, algorithm) === normalized) {
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
exports.TotpAlgorithm = TotpAlgorithm;
|
|
140
|
+
exports.buildOtpAuthUri = buildOtpAuthUri;
|
|
141
|
+
exports.generateCode = generateCode;
|
|
142
|
+
exports.generateSecret = generateSecret;
|
|
143
|
+
exports.verifyCode = verifyCode;
|
|
144
|
+
//# sourceMappingURL=crypto.cjs.map
|
|
145
|
+
//# sourceMappingURL=crypto.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/crypto/base32.ts","../src/crypto/secret.ts","../src/enums/totp-algorithm.ts","../src/crypto/uri.ts","../src/crypto/verify.ts"],"names":["randomBytes","TotpAlgorithm","createHmac"],"mappings":";;;;;;;AAOA,IAAM,QAAA,GAAW,kCAAA;AAGV,SAAS,aAAa,MAAA,EAAwB;AACnD,EAAA,IAAI,IAAA,GAAO,CAAA;AACX,EAAA,IAAI,KAAA,GAAQ,CAAA;AACZ,EAAA,IAAI,MAAA,GAAS,EAAA;AAEb,EAAA,KAAA,MAAW,QAAQ,MAAA,EAAQ;AACzB,IAAA,KAAA,GAAS,SAAS,CAAA,GAAK,IAAA;AACvB,IAAA,IAAA,IAAQ,CAAA;AAER,IAAA,OAAO,QAAQ,CAAA,EAAG;AAChB,MAAA,MAAA,IAAU,QAAA,CAAU,KAAA,KAAW,IAAA,GAAO,CAAA,GAAM,EAAE,CAAA;AAC9C,MAAA,IAAA,IAAQ,CAAA;AAAA,IACV;AAAA,EACF;AAGA,EAAA,IAAI,OAAO,CAAA,EAAG;AACZ,IAAA,MAAA,IAAU,QAAA,CAAU,KAAA,IAAU,CAAA,GAAI,IAAA,GAAS,EAAE,CAAA;AAAA,EAC/C;AAEA,EAAA,OAAO,MAAA;AACT;AAGO,SAAS,aAAa,OAAA,EAAyB;AACpD,EAAA,MAAM,aAAa,OAAA,CAAQ,OAAA,CAAQ,KAAA,EAAO,EAAE,EAAE,WAAA,EAAY;AAC1D,EAAA,IAAI,IAAA,GAAO,CAAA;AACX,EAAA,IAAI,KAAA,GAAQ,CAAA;AACZ,EAAA,MAAM,QAAkB,EAAC;AAEzB,EAAA,KAAA,MAAW,QAAQ,UAAA,EAAY;AAC7B,IAAA,MAAM,KAAA,GAAQ,QAAA,CAAS,OAAA,CAAQ,IAAI,CAAA;AACnC,IAAA,IAAI,UAAU,EAAA,EAAI;AAChB,MAAA,MAAM,IAAI,KAAA,CAAM,CAAA,0BAAA,EAA6B,IAAI,CAAA,CAAE,CAAA;AAAA,IACrD;AAEA,IAAA,KAAA,GAAS,SAAS,CAAA,GAAK,KAAA;AACvB,IAAA,IAAA,IAAQ,CAAA;AAER,IAAA,IAAI,QAAQ,CAAA,EAAG;AACb,MAAA,KAAA,CAAM,IAAA,CAAM,KAAA,KAAW,IAAA,GAAO,CAAA,GAAM,GAAG,CAAA;AACvC,MAAA,IAAA,IAAQ,CAAA;AAAA,IACV;AAAA,EACF;AAEA,EAAA,OAAO,MAAA,CAAO,KAAK,KAAK,CAAA;AAC1B;;;ACpDA,IAAM,mBAAA,GAAsB,EAAA;AAMrB,SAAS,eAAe,OAAA,EAAuC;AACpE,EAAA,MAAM,UAAA,GAAa,SAAS,MAAA,IAAU,mBAAA;AAEtC,EAAA,IAAI,aAAa,EAAA,EAAI;AACnB,IAAA,MAAM,IAAI,MAAM,yCAAyC,CAAA;AAAA,EAC3D;AACA,EAAA,OAAO,YAAA,CAAaA,kBAAA,CAAY,UAAU,CAAC,CAAA;AAC7C;;;AChBO,IAAK,aAAA,qBAAAC,cAAAA,KAAL;AACL,EAAAA,eAAA,MAAA,CAAA,GAAO,MAAA;AACP,EAAAA,eAAA,QAAA,CAAA,GAAS,QAAA;AACT,EAAAA,eAAA,QAAA,CAAA,GAAS,QAAA;AAHC,EAAA,OAAAA,cAAAA;AAAA,CAAA,EAAA,aAAA,IAAA,EAAA;;;ACcL,SAAS,gBAAgB,OAAA,EAAyC;AACvE,EAAA,MAAM,MAAA,GAAS,QAAQ,MAAA,IAAU,CAAA;AACjC,EAAA,MAAM,MAAA,GAAS,QAAQ,MAAA,IAAU,EAAA;AACjC,EAAA,MAAM,YAAY,OAAA,CAAQ,SAAA,IAAA,MAAA;AAE1B,EAAA,MAAM,KAAA,GAAQ,mBAAmB,CAAA,EAAG,OAAA,CAAQ,MAAM,CAAA,CAAA,EAAI,OAAA,CAAQ,WAAW,CAAA,CAAE,CAAA;AAC3E,EAAA,MAAM,MAAA,GAAS,IAAI,eAAA,CAAgB;AAAA,IACjC,QAAQ,OAAA,CAAQ,MAAA;AAAA,IAChB,QAAQ,OAAA,CAAQ,MAAA;AAAA,IAChB,SAAA;AAAA,IACA,MAAA,EAAQ,OAAO,MAAM,CAAA;AAAA,IACrB,MAAA,EAAQ,OAAO,MAAM;AAAA,GACtB,CAAA;AAED,EAAA,OAAO,CAAA,eAAA,EAAkB,KAAK,CAAA,CAAA,EAAI,MAAA,CAAO,UAAU,CAAA,CAAA;AACrD;ACPA,SAAS,oBAAoB,SAAA,EAAkC;AAC7D,EAAA,QAAQ,SAAA;AAAW,IACjB,KAAA,QAAA;AACE,MAAA,OAAO,QAAA;AAAA,IACT,KAAA,QAAA;AACE,MAAA,OAAO,QAAA;AAAA,IACT;AACE,MAAA,OAAO,MAAA;AAAA;AAEb;AAKA,SAAS,gBAAgB,OAAA,EAAyB;AAChD,EAAA,MAAM,GAAA,GAAM,MAAA,CAAO,KAAA,CAAM,CAAC,CAAA;AAC1B,EAAA,IAAI,KAAA,GAAQ,OAAA;AACZ,EAAA,KAAA,IAAS,CAAA,GAAI,CAAA,EAAG,CAAA,IAAK,CAAA,EAAG,CAAA,EAAA,EAAK;AAC3B,IAAA,GAAA,CAAI,CAAC,IAAI,KAAA,GAAQ,GAAA;AACjB,IAAA,KAAA,GAAQ,IAAA,CAAK,KAAA,CAAM,KAAA,GAAQ,GAAG,CAAA;AAAA,EAChC;AACA,EAAA,OAAO,GAAA;AACT;AAOA,SAAS,IAAA,CACP,MAAA,EACA,OAAA,EACA,MAAA,EACA,SAAA,EACQ;AACR,EAAA,MAAM,IAAA,GAAOC,iBAAA,CAAW,mBAAA,CAAoB,SAAS,GAAG,MAAM,CAAA;AAC9D,EAAA,IAAA,CAAK,MAAA,CAAO,eAAA,CAAgB,OAAO,CAAC,CAAA;AACpC,EAAA,MAAM,MAAA,GAAS,KAAK,MAAA,EAAO;AAG3B,EAAA,MAAM,MAAA,GAAS,MAAA,CAAO,MAAA,CAAO,MAAA,GAAS,CAAC,CAAA,GAAK,EAAA;AAC5C,EAAA,MAAM,MAAA,GAAA,CACF,OAAO,MAAM,CAAA,GAAK,QAAS,EAAA,GAAA,CAC3B,MAAA,CAAO,SAAS,CAAC,CAAA,GAAK,QAAS,EAAA,GAAA,CAC/B,MAAA,CAAO,SAAS,CAAC,CAAA,GAAK,QAAS,CAAA,GAChC,MAAA,CAAO,MAAA,GAAS,CAAC,CAAA,GAAK,GAAA;AAEzB,EAAA,MAAM,GAAA,GAAM,SAAS,EAAA,IAAM,MAAA;AAC3B,EAAA,OAAO,GAAA,CAAI,QAAA,EAAS,CAAE,QAAA,CAAS,QAAQ,GAAG,CAAA;AAC5C;AAEA,SAAS,iBAAiB,SAAA,EAA4B;AACpD,EAAA,OAAO,SAAA,IAAa,KAAK,GAAA,EAAI;AAC/B;AAGO,SAAS,YAAA,CACd,QACA,OAAA,EACQ;AACR,EAAA,MAAM,MAAA,GAAS,SAAS,MAAA,IAAU,EAAA;AAClC,EAAA,MAAM,MAAA,GAAS,SAAS,MAAA,IAAU,CAAA;AAClC,EAAA,MAAM,YAAY,OAAA,EAAS,SAAA,IAAA,MAAA;AAC3B,EAAA,MAAM,SAAA,GAAY,gBAAA,CAAiB,OAAA,EAAS,SAAS,CAAA;AACrD,EAAA,MAAM,OAAA,GAAU,IAAA,CAAK,KAAA,CAAM,SAAA,GAAY,MAAO,MAAM,CAAA;AACpD,EAAA,MAAM,GAAA,GAAM,aAAa,MAAM,CAAA;AAC/B,EAAA,OAAO,IAAA,CAAK,GAAA,EAAK,OAAA,EAAS,MAAA,EAAQ,SAAS,CAAA;AAC7C;AAOO,SAAS,UAAA,CACd,MAAA,EACA,IAAA,EACA,OAAA,EACS;AACT,EAAA,MAAM,MAAA,GAAS,SAAS,MAAA,IAAU,EAAA;AAClC,EAAA,MAAM,MAAA,GAAS,SAAS,MAAA,IAAU,CAAA;AAClC,EAAA,MAAM,MAAA,GAAS,SAAS,MAAA,IAAU,CAAA;AAClC,EAAA,MAAM,YAAY,OAAA,EAAS,SAAA,IAAA,MAAA;AAC3B,EAAA,MAAM,SAAA,GAAY,gBAAA,CAAiB,OAAA,EAAS,SAAS,CAAA;AACrD,EAAA,MAAM,OAAA,GAAU,IAAA,CAAK,KAAA,CAAM,SAAA,GAAY,MAAO,MAAM,CAAA;AACpD,EAAA,MAAM,GAAA,GAAM,aAAa,MAAM,CAAA;AAE/B,EAAA,MAAM,UAAA,GAAa,IAAA,CAAK,OAAA,CAAQ,KAAA,EAAO,EAAE,CAAA;AACzC,EAAA,IAAI,CAAC,OAAA,CAAQ,IAAA,CAAK,UAAU,CAAA,IAAK,UAAA,CAAW,WAAW,MAAA,EAAQ;AAC7D,IAAA,OAAO,KAAA;AAAA,EACT;AAEA,EAAA,KAAA,IAAS,KAAA,GAAQ,CAAC,MAAA,EAAQ,KAAA,IAAS,QAAQ,KAAA,EAAA,EAAS;AAClD,IAAA,IAAI,KAAK,GAAA,EAAK,OAAA,GAAU,OAAO,MAAA,EAAQ,SAAS,MAAM,UAAA,EAAY;AAChE,MAAA,OAAO,IAAA;AAAA,IACT;AAAA,EACF;AAEA,EAAA,OAAO,KAAA;AACT","file":"crypto.cjs","sourcesContent":["/**\n * RFC 4648 Base32 encode/decode for TOTP shared secrets.\n *\n * Authenticator apps and otpauth:// URIs store secrets as Base32 text (A–Z, 2–7),\n * not raw bytes. generateSecret() encodes random bytes; verifyCode() decodes\n * before HMAC. No padding is required for typical OTP secret lengths.\n */\nconst ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'\n\n/** Pack raw bytes into a Base32 string (5 bits per alphabet character). */\nexport function encodeBase32(buffer: Buffer): string {\n let bits = 0\n let value = 0\n let output = ''\n\n for (const byte of buffer) {\n value = (value << 8) | byte\n bits += 8\n\n while (bits >= 5) {\n output += ALPHABET[(value >>> (bits - 5)) & 31]\n bits -= 5\n }\n }\n\n // Emit final partial quintet if the byte stream did not align on 5-bit boundaries.\n if (bits > 0) {\n output += ALPHABET[(value << (5 - bits)) & 31]\n }\n\n return output\n}\n\n/** Unpack a Base32 secret string into the raw key bytes used by HMAC. */\nexport function decodeBase32(encoded: string): Buffer {\n const normalized = encoded.replace(/=+$/, '').toUpperCase()\n let bits = 0\n let value = 0\n const bytes: number[] = []\n\n for (const char of normalized) {\n const index = ALPHABET.indexOf(char)\n if (index === -1) {\n throw new Error(`Invalid base32 character: ${char}`)\n }\n\n value = (value << 5) | index\n bits += 5\n\n if (bits >= 8) {\n bytes.push((value >>> (bits - 8)) & 255)\n bits -= 8\n }\n }\n\n return Buffer.from(bytes)\n}\n","import { randomBytes } from 'crypto'\nimport { encodeBase32 } from './base32.js'\n\n/** 20 bytes → ~32 Base32 chars; matches common authenticator defaults. */\nconst DEFAULT_BYTE_LENGTH = 20\n\n/**\n * Create a cryptographically random TOTP shared secret (Base32).\n * Consumers persist this via StorageAdapter during enroll().\n */\nexport function generateSecret(options?: { length?: number }): string {\n const byteLength = options?.length ?? DEFAULT_BYTE_LENGTH\n // RFC 4226 recommends at least 128 bits (16 bytes) of entropy for the secret.\n if (byteLength < 16) {\n throw new Error('Secret length must be at least 16 bytes')\n }\n return encodeBase32(randomBytes(byteLength))\n}\n","/** HMAC algorithm for TOTP (otpauth `algorithm` param and HMAC digest). */\nexport enum TotpAlgorithm {\n SHA1 = 'SHA1',\n SHA256 = 'SHA256',\n SHA512 = 'SHA512',\n}\n","import { TotpAlgorithm } from '../enums/totp-algorithm.js'\n\nexport interface BuildOtpAuthUriOptions {\n secret: string\n accountName: string\n issuer: string\n digits?: number\n period?: number\n algorithm?: TotpAlgorithm\n}\n\n/**\n * Build an otpauth://totp/ URI for QR codes and manual entry in authenticator apps.\n * Label format follows the de-facto `Issuer:account` convention (both URL-encoded).\n */\nexport function buildOtpAuthUri(options: BuildOtpAuthUriOptions): string {\n const digits = options.digits ?? 6\n const period = options.period ?? 30\n const algorithm = options.algorithm ?? TotpAlgorithm.SHA1\n\n const label = encodeURIComponent(`${options.issuer}:${options.accountName}`)\n const params = new URLSearchParams({\n secret: options.secret,\n issuer: options.issuer,\n algorithm,\n digits: String(digits),\n period: String(period),\n })\n\n return `otpauth://totp/${label}?${params.toString()}`\n}\n","/**\n * RFC 4226 (HOTP) and RFC 6238 (TOTP) — pure functions, no I/O.\n *\n * TOTP is HOTP with counter = floor(unixTime / period). Injectable `timestamp`\n * keeps tests deterministic without mocking Date or global time.\n */\nimport { createHmac } from 'crypto'\nimport { TotpAlgorithm } from '../enums/totp-algorithm.js'\nimport { decodeBase32 } from './base32.js'\n\nexport interface TotpOptions {\n period?: number\n digits?: number\n /** Defaults to Date.now(); pass in tests for fixed codes. */\n timestamp?: number\n algorithm?: TotpAlgorithm\n}\n\nexport interface VerifyCodeOptions extends TotpOptions {\n /** Drift tolerance in 30s (or custom) periods — checks counter ± window. */\n window?: number\n}\n\nfunction algorithmToHashName(algorithm: TotpAlgorithm): string {\n switch (algorithm) {\n case TotpAlgorithm.SHA256:\n return 'sha256'\n case TotpAlgorithm.SHA512:\n return 'sha512'\n default:\n return 'sha1'\n }\n}\n\n/**\n * RFC 4226 §5.1: counter must be an 8-byte big-endian integer in the HMAC input.\n */\nfunction counterToBuffer(counter: number): Buffer {\n const buf = Buffer.alloc(8)\n let value = counter\n for (let i = 7; i >= 0; i--) {\n buf[i] = value & 0xff\n value = Math.floor(value / 256)\n }\n return buf\n}\n\n/**\n * HMAC-based One-Time Password (RFC 4226).\n * TOTP calls this with a time-derived counter; verifyCode may call it for\n * neighboring counters when `window` > 0 (clock skew).\n */\nfunction hotp(\n secret: Buffer,\n counter: number,\n digits: number,\n algorithm: TotpAlgorithm,\n): string {\n const hmac = createHmac(algorithmToHashName(algorithm), secret)\n hmac.update(counterToBuffer(counter))\n const digest = hmac.digest()\n\n // Dynamic truncation (RFC 4226 §5.3): derive a 31-bit value from the HMAC digest.\n const offset = digest[digest.length - 1]! & 0x0f\n const binary =\n ((digest[offset]! & 0x7f) << 24) |\n ((digest[offset + 1]! & 0xff) << 16) |\n ((digest[offset + 2]! & 0xff) << 8) |\n (digest[offset + 3]! & 0xff)\n\n const otp = binary % 10 ** digits\n return otp.toString().padStart(digits, '0')\n}\n\nfunction resolveTimestamp(timestamp?: number): number {\n return timestamp ?? Date.now()\n}\n\n/** Generate the TOTP code for the current (or injected) time slice. */\nexport function generateCode(\n secret: string,\n options?: TotpOptions,\n): string {\n const period = options?.period ?? 30\n const digits = options?.digits ?? 6\n const algorithm = options?.algorithm ?? TotpAlgorithm.SHA1\n const timestamp = resolveTimestamp(options?.timestamp)\n const counter = Math.floor(timestamp / 1000 / period)\n const key = decodeBase32(secret)\n return hotp(key, counter, digits, algorithm)\n}\n\n/**\n * Constant-time comparison is not used here; timing leaks on OTP verify are a\n * secondary concern vs. rate limiting (out of scope for v1). Returns false for\n * malformed input instead of throwing.\n */\nexport function verifyCode(\n secret: string,\n code: string,\n options?: VerifyCodeOptions,\n): boolean {\n const period = options?.period ?? 30\n const digits = options?.digits ?? 6\n const window = options?.window ?? 0\n const algorithm = options?.algorithm ?? TotpAlgorithm.SHA1\n const timestamp = resolveTimestamp(options?.timestamp)\n const counter = Math.floor(timestamp / 1000 / period)\n const key = decodeBase32(secret)\n\n const normalized = code.replace(/\\s/g, '')\n if (!/^\\d+$/.test(normalized) || normalized.length !== digits) {\n return false\n }\n\n for (let drift = -window; drift <= window; drift++) {\n if (hotp(key, counter + drift, digits, algorithm) === normalized) {\n return true\n }\n }\n\n return false\n}\n"]}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { T as TotpAlgorithm } from './totp-algorithm-CUpdtI9d.cjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Create a cryptographically random TOTP shared secret (Base32).
|
|
5
|
+
* Consumers persist this via StorageAdapter during enroll().
|
|
6
|
+
*/
|
|
7
|
+
declare function generateSecret(options?: {
|
|
8
|
+
length?: number;
|
|
9
|
+
}): string;
|
|
10
|
+
|
|
11
|
+
interface BuildOtpAuthUriOptions {
|
|
12
|
+
secret: string;
|
|
13
|
+
accountName: string;
|
|
14
|
+
issuer: string;
|
|
15
|
+
digits?: number;
|
|
16
|
+
period?: number;
|
|
17
|
+
algorithm?: TotpAlgorithm;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Build an otpauth://totp/ URI for QR codes and manual entry in authenticator apps.
|
|
21
|
+
* Label format follows the de-facto `Issuer:account` convention (both URL-encoded).
|
|
22
|
+
*/
|
|
23
|
+
declare function buildOtpAuthUri(options: BuildOtpAuthUriOptions): string;
|
|
24
|
+
|
|
25
|
+
interface TotpOptions {
|
|
26
|
+
period?: number;
|
|
27
|
+
digits?: number;
|
|
28
|
+
/** Defaults to Date.now(); pass in tests for fixed codes. */
|
|
29
|
+
timestamp?: number;
|
|
30
|
+
algorithm?: TotpAlgorithm;
|
|
31
|
+
}
|
|
32
|
+
interface VerifyCodeOptions extends TotpOptions {
|
|
33
|
+
/** Drift tolerance in 30s (or custom) periods — checks counter ± window. */
|
|
34
|
+
window?: number;
|
|
35
|
+
}
|
|
36
|
+
/** Generate the TOTP code for the current (or injected) time slice. */
|
|
37
|
+
declare function generateCode(secret: string, options?: TotpOptions): string;
|
|
38
|
+
/**
|
|
39
|
+
* Constant-time comparison is not used here; timing leaks on OTP verify are a
|
|
40
|
+
* secondary concern vs. rate limiting (out of scope for v1). Returns false for
|
|
41
|
+
* malformed input instead of throwing.
|
|
42
|
+
*/
|
|
43
|
+
declare function verifyCode(secret: string, code: string, options?: VerifyCodeOptions): boolean;
|
|
44
|
+
|
|
45
|
+
export { type BuildOtpAuthUriOptions, TotpAlgorithm, type TotpOptions, type VerifyCodeOptions, buildOtpAuthUri, generateCode, generateSecret, verifyCode };
|
package/dist/crypto.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { T as TotpAlgorithm } from './totp-algorithm-CUpdtI9d.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Create a cryptographically random TOTP shared secret (Base32).
|
|
5
|
+
* Consumers persist this via StorageAdapter during enroll().
|
|
6
|
+
*/
|
|
7
|
+
declare function generateSecret(options?: {
|
|
8
|
+
length?: number;
|
|
9
|
+
}): string;
|
|
10
|
+
|
|
11
|
+
interface BuildOtpAuthUriOptions {
|
|
12
|
+
secret: string;
|
|
13
|
+
accountName: string;
|
|
14
|
+
issuer: string;
|
|
15
|
+
digits?: number;
|
|
16
|
+
period?: number;
|
|
17
|
+
algorithm?: TotpAlgorithm;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Build an otpauth://totp/ URI for QR codes and manual entry in authenticator apps.
|
|
21
|
+
* Label format follows the de-facto `Issuer:account` convention (both URL-encoded).
|
|
22
|
+
*/
|
|
23
|
+
declare function buildOtpAuthUri(options: BuildOtpAuthUriOptions): string;
|
|
24
|
+
|
|
25
|
+
interface TotpOptions {
|
|
26
|
+
period?: number;
|
|
27
|
+
digits?: number;
|
|
28
|
+
/** Defaults to Date.now(); pass in tests for fixed codes. */
|
|
29
|
+
timestamp?: number;
|
|
30
|
+
algorithm?: TotpAlgorithm;
|
|
31
|
+
}
|
|
32
|
+
interface VerifyCodeOptions extends TotpOptions {
|
|
33
|
+
/** Drift tolerance in 30s (or custom) periods — checks counter ± window. */
|
|
34
|
+
window?: number;
|
|
35
|
+
}
|
|
36
|
+
/** Generate the TOTP code for the current (or injected) time slice. */
|
|
37
|
+
declare function generateCode(secret: string, options?: TotpOptions): string;
|
|
38
|
+
/**
|
|
39
|
+
* Constant-time comparison is not used here; timing leaks on OTP verify are a
|
|
40
|
+
* secondary concern vs. rate limiting (out of scope for v1). Returns false for
|
|
41
|
+
* malformed input instead of throwing.
|
|
42
|
+
*/
|
|
43
|
+
declare function verifyCode(secret: string, code: string, options?: VerifyCodeOptions): boolean;
|
|
44
|
+
|
|
45
|
+
export { type BuildOtpAuthUriOptions, TotpAlgorithm, type TotpOptions, type VerifyCodeOptions, buildOtpAuthUri, generateCode, generateSecret, verifyCode };
|
package/dist/crypto.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { randomBytes, createHmac } from 'crypto';
|
|
2
|
+
|
|
3
|
+
// src/crypto/secret.ts
|
|
4
|
+
|
|
5
|
+
// src/crypto/base32.ts
|
|
6
|
+
var ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
7
|
+
function encodeBase32(buffer) {
|
|
8
|
+
let bits = 0;
|
|
9
|
+
let value = 0;
|
|
10
|
+
let output = "";
|
|
11
|
+
for (const byte of buffer) {
|
|
12
|
+
value = value << 8 | byte;
|
|
13
|
+
bits += 8;
|
|
14
|
+
while (bits >= 5) {
|
|
15
|
+
output += ALPHABET[value >>> bits - 5 & 31];
|
|
16
|
+
bits -= 5;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
if (bits > 0) {
|
|
20
|
+
output += ALPHABET[value << 5 - bits & 31];
|
|
21
|
+
}
|
|
22
|
+
return output;
|
|
23
|
+
}
|
|
24
|
+
function decodeBase32(encoded) {
|
|
25
|
+
const normalized = encoded.replace(/=+$/, "").toUpperCase();
|
|
26
|
+
let bits = 0;
|
|
27
|
+
let value = 0;
|
|
28
|
+
const bytes = [];
|
|
29
|
+
for (const char of normalized) {
|
|
30
|
+
const index = ALPHABET.indexOf(char);
|
|
31
|
+
if (index === -1) {
|
|
32
|
+
throw new Error(`Invalid base32 character: ${char}`);
|
|
33
|
+
}
|
|
34
|
+
value = value << 5 | index;
|
|
35
|
+
bits += 5;
|
|
36
|
+
if (bits >= 8) {
|
|
37
|
+
bytes.push(value >>> bits - 8 & 255);
|
|
38
|
+
bits -= 8;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return Buffer.from(bytes);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// src/crypto/secret.ts
|
|
45
|
+
var DEFAULT_BYTE_LENGTH = 20;
|
|
46
|
+
function generateSecret(options) {
|
|
47
|
+
const byteLength = options?.length ?? DEFAULT_BYTE_LENGTH;
|
|
48
|
+
if (byteLength < 16) {
|
|
49
|
+
throw new Error("Secret length must be at least 16 bytes");
|
|
50
|
+
}
|
|
51
|
+
return encodeBase32(randomBytes(byteLength));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// src/enums/totp-algorithm.ts
|
|
55
|
+
var TotpAlgorithm = /* @__PURE__ */ ((TotpAlgorithm2) => {
|
|
56
|
+
TotpAlgorithm2["SHA1"] = "SHA1";
|
|
57
|
+
TotpAlgorithm2["SHA256"] = "SHA256";
|
|
58
|
+
TotpAlgorithm2["SHA512"] = "SHA512";
|
|
59
|
+
return TotpAlgorithm2;
|
|
60
|
+
})(TotpAlgorithm || {});
|
|
61
|
+
|
|
62
|
+
// src/crypto/uri.ts
|
|
63
|
+
function buildOtpAuthUri(options) {
|
|
64
|
+
const digits = options.digits ?? 6;
|
|
65
|
+
const period = options.period ?? 30;
|
|
66
|
+
const algorithm = options.algorithm ?? "SHA1" /* SHA1 */;
|
|
67
|
+
const label = encodeURIComponent(`${options.issuer}:${options.accountName}`);
|
|
68
|
+
const params = new URLSearchParams({
|
|
69
|
+
secret: options.secret,
|
|
70
|
+
issuer: options.issuer,
|
|
71
|
+
algorithm,
|
|
72
|
+
digits: String(digits),
|
|
73
|
+
period: String(period)
|
|
74
|
+
});
|
|
75
|
+
return `otpauth://totp/${label}?${params.toString()}`;
|
|
76
|
+
}
|
|
77
|
+
function algorithmToHashName(algorithm) {
|
|
78
|
+
switch (algorithm) {
|
|
79
|
+
case "SHA256" /* SHA256 */:
|
|
80
|
+
return "sha256";
|
|
81
|
+
case "SHA512" /* SHA512 */:
|
|
82
|
+
return "sha512";
|
|
83
|
+
default:
|
|
84
|
+
return "sha1";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function counterToBuffer(counter) {
|
|
88
|
+
const buf = Buffer.alloc(8);
|
|
89
|
+
let value = counter;
|
|
90
|
+
for (let i = 7; i >= 0; i--) {
|
|
91
|
+
buf[i] = value & 255;
|
|
92
|
+
value = Math.floor(value / 256);
|
|
93
|
+
}
|
|
94
|
+
return buf;
|
|
95
|
+
}
|
|
96
|
+
function hotp(secret, counter, digits, algorithm) {
|
|
97
|
+
const hmac = createHmac(algorithmToHashName(algorithm), secret);
|
|
98
|
+
hmac.update(counterToBuffer(counter));
|
|
99
|
+
const digest = hmac.digest();
|
|
100
|
+
const offset = digest[digest.length - 1] & 15;
|
|
101
|
+
const binary = (digest[offset] & 127) << 24 | (digest[offset + 1] & 255) << 16 | (digest[offset + 2] & 255) << 8 | digest[offset + 3] & 255;
|
|
102
|
+
const otp = binary % 10 ** digits;
|
|
103
|
+
return otp.toString().padStart(digits, "0");
|
|
104
|
+
}
|
|
105
|
+
function resolveTimestamp(timestamp) {
|
|
106
|
+
return timestamp ?? Date.now();
|
|
107
|
+
}
|
|
108
|
+
function generateCode(secret, options) {
|
|
109
|
+
const period = options?.period ?? 30;
|
|
110
|
+
const digits = options?.digits ?? 6;
|
|
111
|
+
const algorithm = options?.algorithm ?? "SHA1" /* SHA1 */;
|
|
112
|
+
const timestamp = resolveTimestamp(options?.timestamp);
|
|
113
|
+
const counter = Math.floor(timestamp / 1e3 / period);
|
|
114
|
+
const key = decodeBase32(secret);
|
|
115
|
+
return hotp(key, counter, digits, algorithm);
|
|
116
|
+
}
|
|
117
|
+
function verifyCode(secret, code, options) {
|
|
118
|
+
const period = options?.period ?? 30;
|
|
119
|
+
const digits = options?.digits ?? 6;
|
|
120
|
+
const window = options?.window ?? 0;
|
|
121
|
+
const algorithm = options?.algorithm ?? "SHA1" /* SHA1 */;
|
|
122
|
+
const timestamp = resolveTimestamp(options?.timestamp);
|
|
123
|
+
const counter = Math.floor(timestamp / 1e3 / period);
|
|
124
|
+
const key = decodeBase32(secret);
|
|
125
|
+
const normalized = code.replace(/\s/g, "");
|
|
126
|
+
if (!/^\d+$/.test(normalized) || normalized.length !== digits) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
for (let drift = -window; drift <= window; drift++) {
|
|
130
|
+
if (hotp(key, counter + drift, digits, algorithm) === normalized) {
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export { TotpAlgorithm, buildOtpAuthUri, generateCode, generateSecret, verifyCode };
|
|
138
|
+
//# sourceMappingURL=crypto.js.map
|
|
139
|
+
//# sourceMappingURL=crypto.js.map
|