@noy-db/on-totp 0.1.0-pre.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/LICENSE +21 -0
- package/README.md +33 -0
- package/dist/index.cjs +148 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +87 -0
- package/dist/index.d.ts +87 -0
- package/dist/index.js +118 -0
- package/dist/index.js.map +1 -0
- package/package.json +67 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 vLannaAi
|
|
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,33 @@
|
|
|
1
|
+
# @noy-db/on-totp
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@noy-db/on-totp)
|
|
4
|
+
|
|
5
|
+
> TOTP (RFC 6238) authenticator-app second factor for noy-db
|
|
6
|
+
|
|
7
|
+
Part of [**`@noy-db/hub`**](https://www.npmjs.com/package/@noy-db/hub) — the zero-knowledge, offline-first, encrypted document store.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pnpm add @noy-db/hub @noy-db/on-totp
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## What it is
|
|
16
|
+
|
|
17
|
+
TOTP (RFC 6238) authenticator-app second factor for noy-db — generate secrets, otpauth:// provisioning URIs, and constant-time code verification. Zero dependencies (HMAC-SHA1 via Web Crypto). Part of the @noy-db/on-* authentication family.
|
|
18
|
+
|
|
19
|
+
## Status
|
|
20
|
+
|
|
21
|
+
**Pre-release** (`0.1.0-pre.1`). API may change before `1.0`.
|
|
22
|
+
|
|
23
|
+
## Documentation
|
|
24
|
+
|
|
25
|
+
See the [main repository](https://github.com/vLannaAi/noy-db#readme) for setup, examples, and the full subsystem catalog.
|
|
26
|
+
|
|
27
|
+
- Source — [`packages/on-totp`](https://github.com/vLannaAi/noy-db/tree/main/packages/on-totp)
|
|
28
|
+
- Issues — [github.com/vLannaAi/noy-db/issues](https://github.com/vLannaAi/noy-db/issues)
|
|
29
|
+
- Spec — [`SPEC.md`](https://github.com/vLannaAi/noy-db/blob/main/SPEC.md)
|
|
30
|
+
|
|
31
|
+
## License
|
|
32
|
+
|
|
33
|
+
[MIT](./LICENSE) © vLannaAi
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
decodeBase32: () => decodeBase32,
|
|
24
|
+
encodeBase32: () => encodeBase32,
|
|
25
|
+
generateCode: () => generateCode,
|
|
26
|
+
generateSecret: () => generateSecret,
|
|
27
|
+
provisioningUri: () => provisioningUri,
|
|
28
|
+
verify: () => verify
|
|
29
|
+
});
|
|
30
|
+
module.exports = __toCommonJS(index_exports);
|
|
31
|
+
var BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
32
|
+
function encodeBase32(bytes) {
|
|
33
|
+
let out = "";
|
|
34
|
+
let buf = 0;
|
|
35
|
+
let bits = 0;
|
|
36
|
+
for (const b of bytes) {
|
|
37
|
+
buf = buf << 8 | b;
|
|
38
|
+
bits += 8;
|
|
39
|
+
while (bits >= 5) {
|
|
40
|
+
bits -= 5;
|
|
41
|
+
out += BASE32_ALPHABET[buf >> bits & 31];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (bits > 0) {
|
|
45
|
+
out += BASE32_ALPHABET[buf << 5 - bits & 31];
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
function decodeBase32(input) {
|
|
50
|
+
const cleaned = input.replace(/\s|=/g, "").toUpperCase();
|
|
51
|
+
const bytes = [];
|
|
52
|
+
let buf = 0;
|
|
53
|
+
let bits = 0;
|
|
54
|
+
for (const ch of cleaned) {
|
|
55
|
+
const v = BASE32_ALPHABET.indexOf(ch);
|
|
56
|
+
if (v < 0) throw new Error(`Invalid Base32 character: "${ch}"`);
|
|
57
|
+
buf = buf << 5 | v;
|
|
58
|
+
bits += 5;
|
|
59
|
+
if (bits >= 8) {
|
|
60
|
+
bits -= 8;
|
|
61
|
+
bytes.push(buf >> bits & 255);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return new Uint8Array(bytes);
|
|
65
|
+
}
|
|
66
|
+
function generateSecret() {
|
|
67
|
+
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(20));
|
|
68
|
+
return encodeBase32(bytes);
|
|
69
|
+
}
|
|
70
|
+
function provisioningUri(secret, options) {
|
|
71
|
+
const issuer = options.issuer;
|
|
72
|
+
const label = issuer ? `${encodeURIComponent(issuer)}:${encodeURIComponent(options.account)}` : encodeURIComponent(options.account);
|
|
73
|
+
const params = new URLSearchParams({
|
|
74
|
+
secret,
|
|
75
|
+
algorithm: options.algorithm ?? "SHA1",
|
|
76
|
+
digits: String(options.digits ?? 6),
|
|
77
|
+
period: String(options.period ?? 30)
|
|
78
|
+
});
|
|
79
|
+
if (issuer) params.set("issuer", issuer);
|
|
80
|
+
return `otpauth://totp/${label}?${params.toString()}`;
|
|
81
|
+
}
|
|
82
|
+
async function generateCode(secret, options = {}) {
|
|
83
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
84
|
+
return codeAt(secret, now, options);
|
|
85
|
+
}
|
|
86
|
+
async function verify(secret, code, options = {}) {
|
|
87
|
+
const digits = options.digits ?? 6;
|
|
88
|
+
if (code.length !== digits) return false;
|
|
89
|
+
const window = options.window ?? 1;
|
|
90
|
+
const now = options.timestamp ?? Math.floor(Date.now() / 1e3);
|
|
91
|
+
const period = options.period ?? 30;
|
|
92
|
+
const step = Math.floor(now / period);
|
|
93
|
+
let matched = false;
|
|
94
|
+
for (let i = -window; i <= window; i++) {
|
|
95
|
+
const candidate = await codeAtStep(secret, step + i, options);
|
|
96
|
+
if (constantTimeEqual(candidate, code)) matched = true;
|
|
97
|
+
}
|
|
98
|
+
return matched;
|
|
99
|
+
}
|
|
100
|
+
async function codeAt(secret, timestamp, options) {
|
|
101
|
+
const period = options.period ?? 30;
|
|
102
|
+
return codeAtStep(secret, Math.floor(timestamp / period), options);
|
|
103
|
+
}
|
|
104
|
+
async function codeAtStep(secret, step, options) {
|
|
105
|
+
const digits = options.digits ?? 6;
|
|
106
|
+
const algorithm = options.algorithm ?? "SHA1";
|
|
107
|
+
const keyBytes = decodeBase32(secret);
|
|
108
|
+
const counter = new Uint8Array(8);
|
|
109
|
+
let s = step;
|
|
110
|
+
for (let i = 7; i >= 0; i--) {
|
|
111
|
+
counter[i] = s & 255;
|
|
112
|
+
s = Math.floor(s / 256);
|
|
113
|
+
}
|
|
114
|
+
const hmacName = algorithm === "SHA1" ? "SHA-1" : algorithm === "SHA256" ? "SHA-256" : "SHA-512";
|
|
115
|
+
const cryptoKey = await globalThis.crypto.subtle.importKey(
|
|
116
|
+
"raw",
|
|
117
|
+
keyBytes,
|
|
118
|
+
{ name: "HMAC", hash: hmacName },
|
|
119
|
+
false,
|
|
120
|
+
["sign"]
|
|
121
|
+
);
|
|
122
|
+
const signature = new Uint8Array(
|
|
123
|
+
await globalThis.crypto.subtle.sign("HMAC", cryptoKey, counter)
|
|
124
|
+
);
|
|
125
|
+
const offset = signature[signature.length - 1] & 15;
|
|
126
|
+
const binary = (signature[offset] & 127) << 24 | (signature[offset + 1] & 255) << 16 | (signature[offset + 2] & 255) << 8 | signature[offset + 3] & 255;
|
|
127
|
+
const modulus = 10 ** digits;
|
|
128
|
+
const otp = (binary % modulus).toString().padStart(digits, "0");
|
|
129
|
+
return otp;
|
|
130
|
+
}
|
|
131
|
+
function constantTimeEqual(a, b) {
|
|
132
|
+
if (a.length !== b.length) return false;
|
|
133
|
+
let diff = 0;
|
|
134
|
+
for (let i = 0; i < a.length; i++) {
|
|
135
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
136
|
+
}
|
|
137
|
+
return diff === 0;
|
|
138
|
+
}
|
|
139
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
140
|
+
0 && (module.exports = {
|
|
141
|
+
decodeBase32,
|
|
142
|
+
encodeBase32,
|
|
143
|
+
generateCode,
|
|
144
|
+
generateSecret,
|
|
145
|
+
provisioningUri,
|
|
146
|
+
verify
|
|
147
|
+
});
|
|
148
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * **@noy-db/on-totp** — TOTP (RFC 6238) authenticator-app second factor.\n *\n * Generates TOTP secrets, produces the standard `otpauth://` provisioning\n * URI that authenticator apps parse from QR codes, and verifies\n * user-entered 6-digit codes in constant time.\n *\n * **Zero dependencies.** HMAC-SHA1 runs on the Web Crypto API\n * (`crypto.subtle`) — same ethos as the rest of noy-db.\n *\n * ## Security model\n *\n * TOTP is a **second factor**, not an independent strength multiplier.\n * The secret must live somewhere the verifier can read (otherwise no\n * one can validate codes), so a compromised verifier leaks the secret.\n * In noy-db the typical pattern is:\n *\n * 1. User sets a passphrase (primary factor). KEK derives via PBKDF2.\n * 2. On enroll, a TOTP secret is generated and stored **encrypted\n * under the KEK** in the user's keyring.\n * 3. On unlock, the user enters passphrase → unwraps the secret →\n * validates the entered code → proceeds only on match.\n *\n * This adds \"something you have\" on top of \"something you know\" but\n * does not defend against a passphrase-KEK compromise. For real\n * hardware-backed second factor, use `@noy-db/on-webauthn`.\n *\n * ## API\n *\n * ```ts\n * import { generateSecret, provisioningUri, verify, generateCode } from '@noy-db/on-totp'\n *\n * // Enroll\n * const secret = generateSecret()\n * const uri = provisioningUri(secret, { account: 'alice@acme.com', issuer: 'Acme' })\n * // Show QR code of `uri`.\n *\n * // Unlock\n * const ok = await verify(secret, userEnteredCode)\n * ```\n *\n * @packageDocumentation\n */\n\n// ─── Base32 encoding (RFC 4648, no padding) ─────────────────────────────\n\nconst BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'\n\n/** Encode raw bytes as RFC 4648 Base32 (no padding — authenticator apps accept this). */\nexport function encodeBase32(bytes: Uint8Array): string {\n let out = ''\n let buf = 0\n let bits = 0\n for (const b of bytes) {\n buf = (buf << 8) | b\n bits += 8\n while (bits >= 5) {\n bits -= 5\n out += BASE32_ALPHABET[(buf >> bits) & 0x1f]\n }\n }\n if (bits > 0) {\n out += BASE32_ALPHABET[(buf << (5 - bits)) & 0x1f]\n }\n return out\n}\n\n/** Decode RFC 4648 Base32 (case-insensitive, padding-tolerant, whitespace-stripped). */\nexport function decodeBase32(input: string): Uint8Array {\n const cleaned = input.replace(/\\s|=/g, '').toUpperCase()\n const bytes: number[] = []\n let buf = 0\n let bits = 0\n for (const ch of cleaned) {\n const v = BASE32_ALPHABET.indexOf(ch)\n if (v < 0) throw new Error(`Invalid Base32 character: \"${ch}\"`)\n buf = (buf << 5) | v\n bits += 5\n if (bits >= 8) {\n bits -= 8\n bytes.push((buf >> bits) & 0xff)\n }\n }\n return new Uint8Array(bytes)\n}\n\n// ─── Secret generation ──────────────────────────────────────────────────\n\n/** Random 20-byte (160-bit) TOTP secret — RFC 4226 recommended minimum. */\nexport function generateSecret(): string {\n const bytes = globalThis.crypto.getRandomValues(new Uint8Array(20))\n return encodeBase32(bytes)\n}\n\n// ─── Provisioning URI ───────────────────────────────────────────────────\n\nexport interface ProvisioningUriOptions {\n /** Account label shown in the authenticator — typically the user's email. */\n readonly account: string\n /** Issuer name shown in the authenticator — your product name. */\n readonly issuer?: string\n /** Code digits. Default 6. */\n readonly digits?: 6 | 7 | 8\n /** Step in seconds. Default 30. */\n readonly period?: number\n /** Hash algorithm. Default SHA1 (compatibility — Google Authenticator etc.). */\n readonly algorithm?: 'SHA1' | 'SHA256' | 'SHA512'\n}\n\n/** Build a standard `otpauth://totp/` URI that authenticator apps parse from QR codes. */\nexport function provisioningUri(secret: string, options: ProvisioningUriOptions): string {\n const issuer = options.issuer\n const label = issuer\n ? `${encodeURIComponent(issuer)}:${encodeURIComponent(options.account)}`\n : encodeURIComponent(options.account)\n\n const params = new URLSearchParams({\n secret,\n algorithm: options.algorithm ?? 'SHA1',\n digits: String(options.digits ?? 6),\n period: String(options.period ?? 30),\n })\n if (issuer) params.set('issuer', issuer)\n\n return `otpauth://totp/${label}?${params.toString()}`\n}\n\n// ─── Code generation + verification ─────────────────────────────────────\n\nexport interface TotpOptions {\n readonly digits?: 6 | 7 | 8\n readonly period?: number\n readonly algorithm?: 'SHA1' | 'SHA256' | 'SHA512'\n}\n\nexport interface VerifyOptions extends TotpOptions {\n /**\n * Clock-drift tolerance window, in steps before/after the current\n * interval. `1` (default) accepts the current step ± 1. `0` is\n * strictly exact — authenticators rarely stay that precise.\n */\n readonly window?: number\n /** Override \"now\" for deterministic tests. */\n readonly timestamp?: number\n}\n\n/** Compute the TOTP code for the given secret at the current time. */\nexport async function generateCode(secret: string, options: TotpOptions = {}): Promise<string> {\n const now = Math.floor(Date.now() / 1000)\n return codeAt(secret, now, options)\n}\n\n/**\n * Verify a user-entered code against the secret. Constant-time comparison;\n * accepts drift within `window` steps.\n */\nexport async function verify(secret: string, code: string, options: VerifyOptions = {}): Promise<boolean> {\n const digits = options.digits ?? 6\n if (code.length !== digits) return false\n const window = options.window ?? 1\n const now = options.timestamp ?? Math.floor(Date.now() / 1000)\n const period = options.period ?? 30\n const step = Math.floor(now / period)\n\n // Gather the candidate codes and do constant-time equality — avoids the\n // wall-clock timing oracle on mismatches.\n let matched = false\n for (let i = -window; i <= window; i++) {\n const candidate = await codeAtStep(secret, step + i, options)\n if (constantTimeEqual(candidate, code)) matched = true\n }\n return matched\n}\n\nasync function codeAt(secret: string, timestamp: number, options: TotpOptions): Promise<string> {\n const period = options.period ?? 30\n return codeAtStep(secret, Math.floor(timestamp / period), options)\n}\n\nasync function codeAtStep(secret: string, step: number, options: TotpOptions): Promise<string> {\n const digits = options.digits ?? 6\n const algorithm = options.algorithm ?? 'SHA1'\n\n const keyBytes = decodeBase32(secret)\n // 8-byte big-endian step counter (HOTP/TOTP convention).\n const counter = new Uint8Array(8)\n let s = step\n for (let i = 7; i >= 0; i--) {\n counter[i] = s & 0xff\n s = Math.floor(s / 256)\n }\n\n const hmacName = algorithm === 'SHA1' ? 'SHA-1' : algorithm === 'SHA256' ? 'SHA-256' : 'SHA-512'\n const cryptoKey = await globalThis.crypto.subtle.importKey(\n 'raw',\n keyBytes as BufferSource,\n { name: 'HMAC', hash: hmacName },\n false,\n ['sign'],\n )\n const signature = new Uint8Array(\n await globalThis.crypto.subtle.sign('HMAC', cryptoKey, counter as BufferSource),\n )\n\n // Dynamic truncation per RFC 4226 §5.3.\n const offset = signature[signature.length - 1]! & 0x0f\n const binary =\n ((signature[offset]! & 0x7f) << 24) |\n ((signature[offset + 1]! & 0xff) << 16) |\n ((signature[offset + 2]! & 0xff) << 8) |\n (signature[offset + 3]! & 0xff)\n\n const modulus = 10 ** digits\n const otp = (binary % modulus).toString().padStart(digits, '0')\n return otp\n}\n\nfunction constantTimeEqual(a: string, b: string): boolean {\n if (a.length !== b.length) return false\n let diff = 0\n for (let i = 0; i < a.length; i++) {\n diff |= a.charCodeAt(i) ^ b.charCodeAt(i)\n }\n return diff === 0\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **@noy-db/on-totp** — TOTP (RFC 6238) authenticator-app second factor.
|
|
3
|
+
*
|
|
4
|
+
* Generates TOTP secrets, produces the standard `otpauth://` provisioning
|
|
5
|
+
* URI that authenticator apps parse from QR codes, and verifies
|
|
6
|
+
* user-entered 6-digit codes in constant time.
|
|
7
|
+
*
|
|
8
|
+
* **Zero dependencies.** HMAC-SHA1 runs on the Web Crypto API
|
|
9
|
+
* (`crypto.subtle`) — same ethos as the rest of noy-db.
|
|
10
|
+
*
|
|
11
|
+
* ## Security model
|
|
12
|
+
*
|
|
13
|
+
* TOTP is a **second factor**, not an independent strength multiplier.
|
|
14
|
+
* The secret must live somewhere the verifier can read (otherwise no
|
|
15
|
+
* one can validate codes), so a compromised verifier leaks the secret.
|
|
16
|
+
* In noy-db the typical pattern is:
|
|
17
|
+
*
|
|
18
|
+
* 1. User sets a passphrase (primary factor). KEK derives via PBKDF2.
|
|
19
|
+
* 2. On enroll, a TOTP secret is generated and stored **encrypted
|
|
20
|
+
* under the KEK** in the user's keyring.
|
|
21
|
+
* 3. On unlock, the user enters passphrase → unwraps the secret →
|
|
22
|
+
* validates the entered code → proceeds only on match.
|
|
23
|
+
*
|
|
24
|
+
* This adds "something you have" on top of "something you know" but
|
|
25
|
+
* does not defend against a passphrase-KEK compromise. For real
|
|
26
|
+
* hardware-backed second factor, use `@noy-db/on-webauthn`.
|
|
27
|
+
*
|
|
28
|
+
* ## API
|
|
29
|
+
*
|
|
30
|
+
* ```ts
|
|
31
|
+
* import { generateSecret, provisioningUri, verify, generateCode } from '@noy-db/on-totp'
|
|
32
|
+
*
|
|
33
|
+
* // Enroll
|
|
34
|
+
* const secret = generateSecret()
|
|
35
|
+
* const uri = provisioningUri(secret, { account: 'alice@acme.com', issuer: 'Acme' })
|
|
36
|
+
* // Show QR code of `uri`.
|
|
37
|
+
*
|
|
38
|
+
* // Unlock
|
|
39
|
+
* const ok = await verify(secret, userEnteredCode)
|
|
40
|
+
* ```
|
|
41
|
+
*
|
|
42
|
+
* @packageDocumentation
|
|
43
|
+
*/
|
|
44
|
+
/** Encode raw bytes as RFC 4648 Base32 (no padding — authenticator apps accept this). */
|
|
45
|
+
declare function encodeBase32(bytes: Uint8Array): string;
|
|
46
|
+
/** Decode RFC 4648 Base32 (case-insensitive, padding-tolerant, whitespace-stripped). */
|
|
47
|
+
declare function decodeBase32(input: string): Uint8Array;
|
|
48
|
+
/** Random 20-byte (160-bit) TOTP secret — RFC 4226 recommended minimum. */
|
|
49
|
+
declare function generateSecret(): string;
|
|
50
|
+
interface ProvisioningUriOptions {
|
|
51
|
+
/** Account label shown in the authenticator — typically the user's email. */
|
|
52
|
+
readonly account: string;
|
|
53
|
+
/** Issuer name shown in the authenticator — your product name. */
|
|
54
|
+
readonly issuer?: string;
|
|
55
|
+
/** Code digits. Default 6. */
|
|
56
|
+
readonly digits?: 6 | 7 | 8;
|
|
57
|
+
/** Step in seconds. Default 30. */
|
|
58
|
+
readonly period?: number;
|
|
59
|
+
/** Hash algorithm. Default SHA1 (compatibility — Google Authenticator etc.). */
|
|
60
|
+
readonly algorithm?: 'SHA1' | 'SHA256' | 'SHA512';
|
|
61
|
+
}
|
|
62
|
+
/** Build a standard `otpauth://totp/` URI that authenticator apps parse from QR codes. */
|
|
63
|
+
declare function provisioningUri(secret: string, options: ProvisioningUriOptions): string;
|
|
64
|
+
interface TotpOptions {
|
|
65
|
+
readonly digits?: 6 | 7 | 8;
|
|
66
|
+
readonly period?: number;
|
|
67
|
+
readonly algorithm?: 'SHA1' | 'SHA256' | 'SHA512';
|
|
68
|
+
}
|
|
69
|
+
interface VerifyOptions extends TotpOptions {
|
|
70
|
+
/**
|
|
71
|
+
* Clock-drift tolerance window, in steps before/after the current
|
|
72
|
+
* interval. `1` (default) accepts the current step ± 1. `0` is
|
|
73
|
+
* strictly exact — authenticators rarely stay that precise.
|
|
74
|
+
*/
|
|
75
|
+
readonly window?: number;
|
|
76
|
+
/** Override "now" for deterministic tests. */
|
|
77
|
+
readonly timestamp?: number;
|
|
78
|
+
}
|
|
79
|
+
/** Compute the TOTP code for the given secret at the current time. */
|
|
80
|
+
declare function generateCode(secret: string, options?: TotpOptions): Promise<string>;
|
|
81
|
+
/**
|
|
82
|
+
* Verify a user-entered code against the secret. Constant-time comparison;
|
|
83
|
+
* accepts drift within `window` steps.
|
|
84
|
+
*/
|
|
85
|
+
declare function verify(secret: string, code: string, options?: VerifyOptions): Promise<boolean>;
|
|
86
|
+
|
|
87
|
+
export { type ProvisioningUriOptions, type TotpOptions, type VerifyOptions, decodeBase32, encodeBase32, generateCode, generateSecret, provisioningUri, verify };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **@noy-db/on-totp** — TOTP (RFC 6238) authenticator-app second factor.
|
|
3
|
+
*
|
|
4
|
+
* Generates TOTP secrets, produces the standard `otpauth://` provisioning
|
|
5
|
+
* URI that authenticator apps parse from QR codes, and verifies
|
|
6
|
+
* user-entered 6-digit codes in constant time.
|
|
7
|
+
*
|
|
8
|
+
* **Zero dependencies.** HMAC-SHA1 runs on the Web Crypto API
|
|
9
|
+
* (`crypto.subtle`) — same ethos as the rest of noy-db.
|
|
10
|
+
*
|
|
11
|
+
* ## Security model
|
|
12
|
+
*
|
|
13
|
+
* TOTP is a **second factor**, not an independent strength multiplier.
|
|
14
|
+
* The secret must live somewhere the verifier can read (otherwise no
|
|
15
|
+
* one can validate codes), so a compromised verifier leaks the secret.
|
|
16
|
+
* In noy-db the typical pattern is:
|
|
17
|
+
*
|
|
18
|
+
* 1. User sets a passphrase (primary factor). KEK derives via PBKDF2.
|
|
19
|
+
* 2. On enroll, a TOTP secret is generated and stored **encrypted
|
|
20
|
+
* under the KEK** in the user's keyring.
|
|
21
|
+
* 3. On unlock, the user enters passphrase → unwraps the secret →
|
|
22
|
+
* validates the entered code → proceeds only on match.
|
|
23
|
+
*
|
|
24
|
+
* This adds "something you have" on top of "something you know" but
|
|
25
|
+
* does not defend against a passphrase-KEK compromise. For real
|
|
26
|
+
* hardware-backed second factor, use `@noy-db/on-webauthn`.
|
|
27
|
+
*
|
|
28
|
+
* ## API
|
|
29
|
+
*
|
|
30
|
+
* ```ts
|
|
31
|
+
* import { generateSecret, provisioningUri, verify, generateCode } from '@noy-db/on-totp'
|
|
32
|
+
*
|
|
33
|
+
* // Enroll
|
|
34
|
+
* const secret = generateSecret()
|
|
35
|
+
* const uri = provisioningUri(secret, { account: 'alice@acme.com', issuer: 'Acme' })
|
|
36
|
+
* // Show QR code of `uri`.
|
|
37
|
+
*
|
|
38
|
+
* // Unlock
|
|
39
|
+
* const ok = await verify(secret, userEnteredCode)
|
|
40
|
+
* ```
|
|
41
|
+
*
|
|
42
|
+
* @packageDocumentation
|
|
43
|
+
*/
|
|
44
|
+
/** Encode raw bytes as RFC 4648 Base32 (no padding — authenticator apps accept this). */
|
|
45
|
+
declare function encodeBase32(bytes: Uint8Array): string;
|
|
46
|
+
/** Decode RFC 4648 Base32 (case-insensitive, padding-tolerant, whitespace-stripped). */
|
|
47
|
+
declare function decodeBase32(input: string): Uint8Array;
|
|
48
|
+
/** Random 20-byte (160-bit) TOTP secret — RFC 4226 recommended minimum. */
|
|
49
|
+
declare function generateSecret(): string;
|
|
50
|
+
interface ProvisioningUriOptions {
|
|
51
|
+
/** Account label shown in the authenticator — typically the user's email. */
|
|
52
|
+
readonly account: string;
|
|
53
|
+
/** Issuer name shown in the authenticator — your product name. */
|
|
54
|
+
readonly issuer?: string;
|
|
55
|
+
/** Code digits. Default 6. */
|
|
56
|
+
readonly digits?: 6 | 7 | 8;
|
|
57
|
+
/** Step in seconds. Default 30. */
|
|
58
|
+
readonly period?: number;
|
|
59
|
+
/** Hash algorithm. Default SHA1 (compatibility — Google Authenticator etc.). */
|
|
60
|
+
readonly algorithm?: 'SHA1' | 'SHA256' | 'SHA512';
|
|
61
|
+
}
|
|
62
|
+
/** Build a standard `otpauth://totp/` URI that authenticator apps parse from QR codes. */
|
|
63
|
+
declare function provisioningUri(secret: string, options: ProvisioningUriOptions): string;
|
|
64
|
+
interface TotpOptions {
|
|
65
|
+
readonly digits?: 6 | 7 | 8;
|
|
66
|
+
readonly period?: number;
|
|
67
|
+
readonly algorithm?: 'SHA1' | 'SHA256' | 'SHA512';
|
|
68
|
+
}
|
|
69
|
+
interface VerifyOptions extends TotpOptions {
|
|
70
|
+
/**
|
|
71
|
+
* Clock-drift tolerance window, in steps before/after the current
|
|
72
|
+
* interval. `1` (default) accepts the current step ± 1. `0` is
|
|
73
|
+
* strictly exact — authenticators rarely stay that precise.
|
|
74
|
+
*/
|
|
75
|
+
readonly window?: number;
|
|
76
|
+
/** Override "now" for deterministic tests. */
|
|
77
|
+
readonly timestamp?: number;
|
|
78
|
+
}
|
|
79
|
+
/** Compute the TOTP code for the given secret at the current time. */
|
|
80
|
+
declare function generateCode(secret: string, options?: TotpOptions): Promise<string>;
|
|
81
|
+
/**
|
|
82
|
+
* Verify a user-entered code against the secret. Constant-time comparison;
|
|
83
|
+
* accepts drift within `window` steps.
|
|
84
|
+
*/
|
|
85
|
+
declare function verify(secret: string, code: string, options?: VerifyOptions): Promise<boolean>;
|
|
86
|
+
|
|
87
|
+
export { type ProvisioningUriOptions, type TotpOptions, type VerifyOptions, decodeBase32, encodeBase32, generateCode, generateSecret, provisioningUri, verify };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
var BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
3
|
+
function encodeBase32(bytes) {
|
|
4
|
+
let out = "";
|
|
5
|
+
let buf = 0;
|
|
6
|
+
let bits = 0;
|
|
7
|
+
for (const b of bytes) {
|
|
8
|
+
buf = buf << 8 | b;
|
|
9
|
+
bits += 8;
|
|
10
|
+
while (bits >= 5) {
|
|
11
|
+
bits -= 5;
|
|
12
|
+
out += BASE32_ALPHABET[buf >> bits & 31];
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
if (bits > 0) {
|
|
16
|
+
out += BASE32_ALPHABET[buf << 5 - bits & 31];
|
|
17
|
+
}
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
function decodeBase32(input) {
|
|
21
|
+
const cleaned = input.replace(/\s|=/g, "").toUpperCase();
|
|
22
|
+
const bytes = [];
|
|
23
|
+
let buf = 0;
|
|
24
|
+
let bits = 0;
|
|
25
|
+
for (const ch of cleaned) {
|
|
26
|
+
const v = BASE32_ALPHABET.indexOf(ch);
|
|
27
|
+
if (v < 0) throw new Error(`Invalid Base32 character: "${ch}"`);
|
|
28
|
+
buf = buf << 5 | v;
|
|
29
|
+
bits += 5;
|
|
30
|
+
if (bits >= 8) {
|
|
31
|
+
bits -= 8;
|
|
32
|
+
bytes.push(buf >> bits & 255);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return new Uint8Array(bytes);
|
|
36
|
+
}
|
|
37
|
+
function generateSecret() {
|
|
38
|
+
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(20));
|
|
39
|
+
return encodeBase32(bytes);
|
|
40
|
+
}
|
|
41
|
+
function provisioningUri(secret, options) {
|
|
42
|
+
const issuer = options.issuer;
|
|
43
|
+
const label = issuer ? `${encodeURIComponent(issuer)}:${encodeURIComponent(options.account)}` : encodeURIComponent(options.account);
|
|
44
|
+
const params = new URLSearchParams({
|
|
45
|
+
secret,
|
|
46
|
+
algorithm: options.algorithm ?? "SHA1",
|
|
47
|
+
digits: String(options.digits ?? 6),
|
|
48
|
+
period: String(options.period ?? 30)
|
|
49
|
+
});
|
|
50
|
+
if (issuer) params.set("issuer", issuer);
|
|
51
|
+
return `otpauth://totp/${label}?${params.toString()}`;
|
|
52
|
+
}
|
|
53
|
+
async function generateCode(secret, options = {}) {
|
|
54
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
55
|
+
return codeAt(secret, now, options);
|
|
56
|
+
}
|
|
57
|
+
async function verify(secret, code, options = {}) {
|
|
58
|
+
const digits = options.digits ?? 6;
|
|
59
|
+
if (code.length !== digits) return false;
|
|
60
|
+
const window = options.window ?? 1;
|
|
61
|
+
const now = options.timestamp ?? Math.floor(Date.now() / 1e3);
|
|
62
|
+
const period = options.period ?? 30;
|
|
63
|
+
const step = Math.floor(now / period);
|
|
64
|
+
let matched = false;
|
|
65
|
+
for (let i = -window; i <= window; i++) {
|
|
66
|
+
const candidate = await codeAtStep(secret, step + i, options);
|
|
67
|
+
if (constantTimeEqual(candidate, code)) matched = true;
|
|
68
|
+
}
|
|
69
|
+
return matched;
|
|
70
|
+
}
|
|
71
|
+
async function codeAt(secret, timestamp, options) {
|
|
72
|
+
const period = options.period ?? 30;
|
|
73
|
+
return codeAtStep(secret, Math.floor(timestamp / period), options);
|
|
74
|
+
}
|
|
75
|
+
async function codeAtStep(secret, step, options) {
|
|
76
|
+
const digits = options.digits ?? 6;
|
|
77
|
+
const algorithm = options.algorithm ?? "SHA1";
|
|
78
|
+
const keyBytes = decodeBase32(secret);
|
|
79
|
+
const counter = new Uint8Array(8);
|
|
80
|
+
let s = step;
|
|
81
|
+
for (let i = 7; i >= 0; i--) {
|
|
82
|
+
counter[i] = s & 255;
|
|
83
|
+
s = Math.floor(s / 256);
|
|
84
|
+
}
|
|
85
|
+
const hmacName = algorithm === "SHA1" ? "SHA-1" : algorithm === "SHA256" ? "SHA-256" : "SHA-512";
|
|
86
|
+
const cryptoKey = await globalThis.crypto.subtle.importKey(
|
|
87
|
+
"raw",
|
|
88
|
+
keyBytes,
|
|
89
|
+
{ name: "HMAC", hash: hmacName },
|
|
90
|
+
false,
|
|
91
|
+
["sign"]
|
|
92
|
+
);
|
|
93
|
+
const signature = new Uint8Array(
|
|
94
|
+
await globalThis.crypto.subtle.sign("HMAC", cryptoKey, counter)
|
|
95
|
+
);
|
|
96
|
+
const offset = signature[signature.length - 1] & 15;
|
|
97
|
+
const binary = (signature[offset] & 127) << 24 | (signature[offset + 1] & 255) << 16 | (signature[offset + 2] & 255) << 8 | signature[offset + 3] & 255;
|
|
98
|
+
const modulus = 10 ** digits;
|
|
99
|
+
const otp = (binary % modulus).toString().padStart(digits, "0");
|
|
100
|
+
return otp;
|
|
101
|
+
}
|
|
102
|
+
function constantTimeEqual(a, b) {
|
|
103
|
+
if (a.length !== b.length) return false;
|
|
104
|
+
let diff = 0;
|
|
105
|
+
for (let i = 0; i < a.length; i++) {
|
|
106
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
107
|
+
}
|
|
108
|
+
return diff === 0;
|
|
109
|
+
}
|
|
110
|
+
export {
|
|
111
|
+
decodeBase32,
|
|
112
|
+
encodeBase32,
|
|
113
|
+
generateCode,
|
|
114
|
+
generateSecret,
|
|
115
|
+
provisioningUri,
|
|
116
|
+
verify
|
|
117
|
+
};
|
|
118
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * **@noy-db/on-totp** — TOTP (RFC 6238) authenticator-app second factor.\n *\n * Generates TOTP secrets, produces the standard `otpauth://` provisioning\n * URI that authenticator apps parse from QR codes, and verifies\n * user-entered 6-digit codes in constant time.\n *\n * **Zero dependencies.** HMAC-SHA1 runs on the Web Crypto API\n * (`crypto.subtle`) — same ethos as the rest of noy-db.\n *\n * ## Security model\n *\n * TOTP is a **second factor**, not an independent strength multiplier.\n * The secret must live somewhere the verifier can read (otherwise no\n * one can validate codes), so a compromised verifier leaks the secret.\n * In noy-db the typical pattern is:\n *\n * 1. User sets a passphrase (primary factor). KEK derives via PBKDF2.\n * 2. On enroll, a TOTP secret is generated and stored **encrypted\n * under the KEK** in the user's keyring.\n * 3. On unlock, the user enters passphrase → unwraps the secret →\n * validates the entered code → proceeds only on match.\n *\n * This adds \"something you have\" on top of \"something you know\" but\n * does not defend against a passphrase-KEK compromise. For real\n * hardware-backed second factor, use `@noy-db/on-webauthn`.\n *\n * ## API\n *\n * ```ts\n * import { generateSecret, provisioningUri, verify, generateCode } from '@noy-db/on-totp'\n *\n * // Enroll\n * const secret = generateSecret()\n * const uri = provisioningUri(secret, { account: 'alice@acme.com', issuer: 'Acme' })\n * // Show QR code of `uri`.\n *\n * // Unlock\n * const ok = await verify(secret, userEnteredCode)\n * ```\n *\n * @packageDocumentation\n */\n\n// ─── Base32 encoding (RFC 4648, no padding) ─────────────────────────────\n\nconst BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'\n\n/** Encode raw bytes as RFC 4648 Base32 (no padding — authenticator apps accept this). */\nexport function encodeBase32(bytes: Uint8Array): string {\n let out = ''\n let buf = 0\n let bits = 0\n for (const b of bytes) {\n buf = (buf << 8) | b\n bits += 8\n while (bits >= 5) {\n bits -= 5\n out += BASE32_ALPHABET[(buf >> bits) & 0x1f]\n }\n }\n if (bits > 0) {\n out += BASE32_ALPHABET[(buf << (5 - bits)) & 0x1f]\n }\n return out\n}\n\n/** Decode RFC 4648 Base32 (case-insensitive, padding-tolerant, whitespace-stripped). */\nexport function decodeBase32(input: string): Uint8Array {\n const cleaned = input.replace(/\\s|=/g, '').toUpperCase()\n const bytes: number[] = []\n let buf = 0\n let bits = 0\n for (const ch of cleaned) {\n const v = BASE32_ALPHABET.indexOf(ch)\n if (v < 0) throw new Error(`Invalid Base32 character: \"${ch}\"`)\n buf = (buf << 5) | v\n bits += 5\n if (bits >= 8) {\n bits -= 8\n bytes.push((buf >> bits) & 0xff)\n }\n }\n return new Uint8Array(bytes)\n}\n\n// ─── Secret generation ──────────────────────────────────────────────────\n\n/** Random 20-byte (160-bit) TOTP secret — RFC 4226 recommended minimum. */\nexport function generateSecret(): string {\n const bytes = globalThis.crypto.getRandomValues(new Uint8Array(20))\n return encodeBase32(bytes)\n}\n\n// ─── Provisioning URI ───────────────────────────────────────────────────\n\nexport interface ProvisioningUriOptions {\n /** Account label shown in the authenticator — typically the user's email. */\n readonly account: string\n /** Issuer name shown in the authenticator — your product name. */\n readonly issuer?: string\n /** Code digits. Default 6. */\n readonly digits?: 6 | 7 | 8\n /** Step in seconds. Default 30. */\n readonly period?: number\n /** Hash algorithm. Default SHA1 (compatibility — Google Authenticator etc.). */\n readonly algorithm?: 'SHA1' | 'SHA256' | 'SHA512'\n}\n\n/** Build a standard `otpauth://totp/` URI that authenticator apps parse from QR codes. */\nexport function provisioningUri(secret: string, options: ProvisioningUriOptions): string {\n const issuer = options.issuer\n const label = issuer\n ? `${encodeURIComponent(issuer)}:${encodeURIComponent(options.account)}`\n : encodeURIComponent(options.account)\n\n const params = new URLSearchParams({\n secret,\n algorithm: options.algorithm ?? 'SHA1',\n digits: String(options.digits ?? 6),\n period: String(options.period ?? 30),\n })\n if (issuer) params.set('issuer', issuer)\n\n return `otpauth://totp/${label}?${params.toString()}`\n}\n\n// ─── Code generation + verification ─────────────────────────────────────\n\nexport interface TotpOptions {\n readonly digits?: 6 | 7 | 8\n readonly period?: number\n readonly algorithm?: 'SHA1' | 'SHA256' | 'SHA512'\n}\n\nexport interface VerifyOptions extends TotpOptions {\n /**\n * Clock-drift tolerance window, in steps before/after the current\n * interval. `1` (default) accepts the current step ± 1. `0` is\n * strictly exact — authenticators rarely stay that precise.\n */\n readonly window?: number\n /** Override \"now\" for deterministic tests. */\n readonly timestamp?: number\n}\n\n/** Compute the TOTP code for the given secret at the current time. */\nexport async function generateCode(secret: string, options: TotpOptions = {}): Promise<string> {\n const now = Math.floor(Date.now() / 1000)\n return codeAt(secret, now, options)\n}\n\n/**\n * Verify a user-entered code against the secret. Constant-time comparison;\n * accepts drift within `window` steps.\n */\nexport async function verify(secret: string, code: string, options: VerifyOptions = {}): Promise<boolean> {\n const digits = options.digits ?? 6\n if (code.length !== digits) return false\n const window = options.window ?? 1\n const now = options.timestamp ?? Math.floor(Date.now() / 1000)\n const period = options.period ?? 30\n const step = Math.floor(now / period)\n\n // Gather the candidate codes and do constant-time equality — avoids the\n // wall-clock timing oracle on mismatches.\n let matched = false\n for (let i = -window; i <= window; i++) {\n const candidate = await codeAtStep(secret, step + i, options)\n if (constantTimeEqual(candidate, code)) matched = true\n }\n return matched\n}\n\nasync function codeAt(secret: string, timestamp: number, options: TotpOptions): Promise<string> {\n const period = options.period ?? 30\n return codeAtStep(secret, Math.floor(timestamp / period), options)\n}\n\nasync function codeAtStep(secret: string, step: number, options: TotpOptions): Promise<string> {\n const digits = options.digits ?? 6\n const algorithm = options.algorithm ?? 'SHA1'\n\n const keyBytes = decodeBase32(secret)\n // 8-byte big-endian step counter (HOTP/TOTP convention).\n const counter = new Uint8Array(8)\n let s = step\n for (let i = 7; i >= 0; i--) {\n counter[i] = s & 0xff\n s = Math.floor(s / 256)\n }\n\n const hmacName = algorithm === 'SHA1' ? 'SHA-1' : algorithm === 'SHA256' ? 'SHA-256' : 'SHA-512'\n const cryptoKey = await globalThis.crypto.subtle.importKey(\n 'raw',\n keyBytes as BufferSource,\n { name: 'HMAC', hash: hmacName },\n false,\n ['sign'],\n )\n const signature = new Uint8Array(\n await globalThis.crypto.subtle.sign('HMAC', cryptoKey, counter as BufferSource),\n )\n\n // Dynamic truncation per RFC 4226 §5.3.\n const offset = signature[signature.length - 1]! & 0x0f\n const binary =\n ((signature[offset]! & 0x7f) << 24) |\n ((signature[offset + 1]! & 0xff) << 16) |\n ((signature[offset + 2]! & 0xff) << 8) |\n (signature[offset + 3]! & 0xff)\n\n const modulus = 10 ** digits\n const otp = (binary % modulus).toString().padStart(digits, '0')\n return otp\n}\n\nfunction constantTimeEqual(a: string, b: string): boolean {\n if (a.length !== b.length) return false\n let diff = 0\n for (let i = 0; i < a.length; i++) {\n diff |= a.charCodeAt(i) ^ b.charCodeAt(i)\n }\n return diff === 0\n}\n"],"mappings":"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","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@noy-db/on-totp",
|
|
3
|
+
"version": "0.1.0-pre.3",
|
|
4
|
+
"description": "TOTP (RFC 6238) authenticator-app second factor for noy-db — generate secrets, otpauth:// provisioning URIs, and constant-time code verification. Zero dependencies (HMAC-SHA1 via Web Crypto). Part of the @noy-db/on-* authentication family.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "vLannaAi <vicio@lanna.ai>",
|
|
7
|
+
"homepage": "https://github.com/vLannaAi/noy-db/tree/main/packages/on-totp#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/vLannaAi/noy-db.git",
|
|
11
|
+
"directory": "packages/on-totp"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/vLannaAi/noy-db/issues"
|
|
15
|
+
},
|
|
16
|
+
"type": "module",
|
|
17
|
+
"sideEffects": false,
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"import": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"default": "./dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"require": {
|
|
25
|
+
"types": "./dist/index.d.cts",
|
|
26
|
+
"default": "./dist/index.cjs"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"main": "./dist/index.cjs",
|
|
31
|
+
"module": "./dist/index.js",
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"files": [
|
|
34
|
+
"dist",
|
|
35
|
+
"README.md",
|
|
36
|
+
"LICENSE"
|
|
37
|
+
],
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=18.0.0"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@noy-db/hub": "0.1.0-pre.3"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/node": "^22.0.0",
|
|
46
|
+
"@noy-db/hub": "0.1.0-pre.3"
|
|
47
|
+
},
|
|
48
|
+
"keywords": [
|
|
49
|
+
"noy-db",
|
|
50
|
+
"on-totp",
|
|
51
|
+
"totp",
|
|
52
|
+
"rfc-6238",
|
|
53
|
+
"2fa",
|
|
54
|
+
"mfa",
|
|
55
|
+
"authenticator"
|
|
56
|
+
],
|
|
57
|
+
"publishConfig": {
|
|
58
|
+
"access": "public",
|
|
59
|
+
"tag": "latest"
|
|
60
|
+
},
|
|
61
|
+
"scripts": {
|
|
62
|
+
"build": "tsup",
|
|
63
|
+
"test": "vitest run",
|
|
64
|
+
"lint": "eslint src/",
|
|
65
|
+
"typecheck": "tsc --noEmit"
|
|
66
|
+
}
|
|
67
|
+
}
|