@noy-db/on-email-otp 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 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-email-otp
2
+
3
+ [![npm](https://img.shields.io/npm/v/%40noy-db/on-email-otp.svg)](https://www.npmjs.com/package/@noy-db/on-email-otp)
4
+
5
+ > Email OTP 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-email-otp
13
+ ```
14
+
15
+ ## What it is
16
+
17
+ Email OTP second factor for noy-db — generates time-boxed one-time codes, delivers via a user-supplied transport (SMTP / SES / Postmark / any fn), and verifies in constant time. 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-email-otp`](https://github.com/vLannaAi/noy-db/tree/main/packages/on-email-otp)
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,118 @@
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
+ issue: () => issue,
24
+ verify: () => verify
25
+ });
26
+ module.exports = __toCommonJS(index_exports);
27
+ async function issue(options) {
28
+ const digits = options.digits ?? 6;
29
+ const ttl = options.ttlSeconds ?? 300;
30
+ const maxAttempts = options.maxAttempts ?? 5;
31
+ const code = randomNumericCode(digits);
32
+ const saltBytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
33
+ const salt = toHex(saltBytes);
34
+ const digest = await hashCodeWithSalt(code, salt);
35
+ const issuedAt = /* @__PURE__ */ new Date();
36
+ const expiresAt = new Date(issuedAt.getTime() + ttl * 1e3);
37
+ const record = {
38
+ email: options.email,
39
+ digest,
40
+ salt,
41
+ issuedAt: issuedAt.toISOString(),
42
+ expiresAt: expiresAt.toISOString(),
43
+ maxAttempts,
44
+ attempts: 0
45
+ };
46
+ await options.transport({
47
+ to: options.email,
48
+ code,
49
+ issuedAt: record.issuedAt,
50
+ expiresAt: record.expiresAt
51
+ });
52
+ return { record };
53
+ }
54
+ async function verify(input, record) {
55
+ if (record.attempts >= record.maxAttempts) {
56
+ return { ok: false, reason: "locked", remainingAttempts: 0 };
57
+ }
58
+ record.attempts += 1;
59
+ if (new Date(record.expiresAt).getTime() <= Date.now()) {
60
+ return { ok: false, reason: "expired", remainingAttempts: record.maxAttempts - record.attempts };
61
+ }
62
+ const digest = await hashCodeWithSalt(input, record.salt);
63
+ if (!constantTimeEqual(digest, record.digest)) {
64
+ return {
65
+ ok: false,
66
+ reason: record.attempts >= record.maxAttempts ? "locked" : "mismatch",
67
+ remainingAttempts: record.maxAttempts - record.attempts
68
+ };
69
+ }
70
+ return { ok: true, remainingAttempts: record.maxAttempts - record.attempts };
71
+ }
72
+ function randomNumericCode(digits) {
73
+ const max = 10 ** digits;
74
+ const bytes = new Uint32Array(1);
75
+ const threshold = Math.floor(4294967295 / max) * max;
76
+ while (true) {
77
+ globalThis.crypto.getRandomValues(bytes);
78
+ if (bytes[0] < threshold) {
79
+ return (bytes[0] % max).toString().padStart(digits, "0");
80
+ }
81
+ }
82
+ }
83
+ async function hashCodeWithSalt(code, saltHex) {
84
+ const enc = new TextEncoder();
85
+ const codeBytes = enc.encode(code);
86
+ const saltBytes = fromHex(saltHex);
87
+ const combined = new Uint8Array(codeBytes.length + saltBytes.length);
88
+ combined.set(codeBytes, 0);
89
+ combined.set(saltBytes, codeBytes.length);
90
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", combined);
91
+ return toHex(new Uint8Array(digest));
92
+ }
93
+ function constantTimeEqual(a, b) {
94
+ if (a.length !== b.length) return false;
95
+ let diff = 0;
96
+ for (let i = 0; i < a.length; i++) {
97
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
98
+ }
99
+ return diff === 0;
100
+ }
101
+ function toHex(bytes) {
102
+ let out = "";
103
+ for (const b of bytes) out += b.toString(16).padStart(2, "0");
104
+ return out;
105
+ }
106
+ function fromHex(hex) {
107
+ const out = new Uint8Array(hex.length / 2);
108
+ for (let i = 0; i < out.length; i++) {
109
+ out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
110
+ }
111
+ return out;
112
+ }
113
+ // Annotate the CommonJS export names for ESM import in node:
114
+ 0 && (module.exports = {
115
+ issue,
116
+ verify
117
+ });
118
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * **@noy-db/on-email-otp** — email OTP second factor.\n *\n * Issues short-lived numeric codes, delivers via a **caller-supplied\n * transport** (SMTP / SES / Postmark / Resend / Mailgun / a test\n * sink), and verifies them in constant time with a one-use burn\n * guarantee.\n *\n * **Why no bundled SMTP client?** Every consumer already has an email\n * sender in their stack — coupling noy-db to a specific SMTP library\n * would pull in Node-only dependencies and complicate browser /\n * edge-worker usage. The transport abstraction is a single-method\n * interface; write a 5-line adapter for whatever you use today.\n *\n * ## Usage\n *\n * ```ts\n * import { issue, verify } from '@noy-db/on-email-otp'\n *\n * // Challenge issue (server side, post-passphrase)\n * const challenge = await issue({\n * email: 'alice@example.com',\n * ttlSeconds: 300,\n * transport: async ({ to, code, expiresAt }) => {\n * await smtp.send({ to, subject: 'Your code', text: `${code} — expires at ${expiresAt}` })\n * },\n * })\n * // Store `challenge.record` somewhere the verifier can retrieve later.\n *\n * // Verify\n * const ok = await verify('123456', challenge.record)\n * ```\n *\n * ## Security model\n *\n * - Codes are 6-digit random numbers (by default) — 10⁶ space is\n * fine because verification is rate-limited at the caller (by\n * burning on success + a `maxAttempts` guard stored in the record).\n * - The record carries `sha256(code + salt)` — the plaintext code is\n * never written to storage. Verification hashes the input with the\n * stored salt and compares in constant time.\n * - `expiresAt` is enforced on every `verify()` — expired records are\n * rejected before the hash comparison.\n * - Burn-on-success is the caller's responsibility — delete the\n * record after a successful verify returns.\n *\n * @packageDocumentation\n */\n\nexport interface EmailOtpTransportArgs {\n readonly to: string\n readonly code: string\n readonly expiresAt: string\n readonly issuedAt: string\n}\n\nexport type EmailOtpTransport = (args: EmailOtpTransportArgs) => Promise<void> | void\n\nexport interface IssueOptions {\n readonly email: string\n /** Seconds before the challenge expires. Default 300 (5 min). */\n readonly ttlSeconds?: number\n /** Code digits. Default 6. */\n readonly digits?: 6 | 7 | 8\n /** Maximum failed attempts before the record should be burned. Default 5. */\n readonly maxAttempts?: number\n /** Caller delivers the code here. Required. */\n readonly transport: EmailOtpTransport\n}\n\nexport interface EmailOtpRecord {\n readonly email: string\n readonly digest: string // hex SHA-256(code ⊕ salt)\n readonly salt: string // hex\n readonly issuedAt: string\n readonly expiresAt: string\n readonly maxAttempts: number\n attempts: number\n}\n\nexport interface IssueResult {\n readonly record: EmailOtpRecord\n}\n\nexport interface VerifyResult {\n readonly ok: boolean\n /**\n * When `ok === false`, `reason` is one of:\n * - `'expired'` — the record's `expiresAt` has passed.\n * - `'mismatch'` — the digest didn't match.\n * - `'locked'` — too many failed attempts; caller should burn.\n */\n readonly reason?: 'expired' | 'mismatch' | 'locked'\n readonly remainingAttempts?: number\n}\n\n/** Issue a new email OTP challenge. Calls the transport in the same tick. */\nexport async function issue(options: IssueOptions): Promise<IssueResult> {\n const digits = options.digits ?? 6\n const ttl = options.ttlSeconds ?? 300\n const maxAttempts = options.maxAttempts ?? 5\n\n const code = randomNumericCode(digits)\n const saltBytes = globalThis.crypto.getRandomValues(new Uint8Array(16))\n const salt = toHex(saltBytes)\n const digest = await hashCodeWithSalt(code, salt)\n\n const issuedAt = new Date()\n const expiresAt = new Date(issuedAt.getTime() + ttl * 1000)\n const record: EmailOtpRecord = {\n email: options.email,\n digest,\n salt,\n issuedAt: issuedAt.toISOString(),\n expiresAt: expiresAt.toISOString(),\n maxAttempts,\n attempts: 0,\n }\n\n await options.transport({\n to: options.email,\n code,\n issuedAt: record.issuedAt,\n expiresAt: record.expiresAt,\n })\n\n return { record }\n}\n\n/**\n * Verify a user-entered code against a stored record. Increments\n * `attempts` on every call (pass or fail). Caller should delete the\n * record on success or when `remainingAttempts === 0`.\n */\nexport async function verify(input: string, record: EmailOtpRecord): Promise<VerifyResult> {\n if (record.attempts >= record.maxAttempts) {\n return { ok: false, reason: 'locked', remainingAttempts: 0 }\n }\n record.attempts += 1\n\n if (new Date(record.expiresAt).getTime() <= Date.now()) {\n return { ok: false, reason: 'expired', remainingAttempts: record.maxAttempts - record.attempts }\n }\n\n const digest = await hashCodeWithSalt(input, record.salt)\n if (!constantTimeEqual(digest, record.digest)) {\n return {\n ok: false,\n reason: record.attempts >= record.maxAttempts ? 'locked' : 'mismatch',\n remainingAttempts: record.maxAttempts - record.attempts,\n }\n }\n return { ok: true, remainingAttempts: record.maxAttempts - record.attempts }\n}\n\n// ── internals ─────────────────────────────────────────────────────────\n\nfunction randomNumericCode(digits: number): string {\n // Rejection sampling to avoid modulo bias.\n const max = 10 ** digits\n const bytes = new Uint32Array(1)\n const threshold = Math.floor(0xffffffff / max) * max\n while (true) {\n globalThis.crypto.getRandomValues(bytes)\n if (bytes[0]! < threshold) {\n return (bytes[0]! % max).toString().padStart(digits, '0')\n }\n }\n}\n\nasync function hashCodeWithSalt(code: string, saltHex: string): Promise<string> {\n const enc = new TextEncoder()\n const codeBytes = enc.encode(code)\n const saltBytes = fromHex(saltHex)\n const combined = new Uint8Array(codeBytes.length + saltBytes.length)\n combined.set(codeBytes, 0)\n combined.set(saltBytes, codeBytes.length)\n const digest = await globalThis.crypto.subtle.digest('SHA-256', combined as BufferSource)\n return toHex(new Uint8Array(digest))\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\nfunction toHex(bytes: Uint8Array): string {\n let out = ''\n for (const b of bytes) out += b.toString(16).padStart(2, '0')\n return out\n}\n\nfunction fromHex(hex: string): Uint8Array {\n const out = new Uint8Array(hex.length / 2)\n for (let i = 0; i < out.length; i++) {\n out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16)\n }\n return out\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAiGA,eAAsB,MAAM,SAA6C;AACvE,QAAM,SAAS,QAAQ,UAAU;AACjC,QAAM,MAAM,QAAQ,cAAc;AAClC,QAAM,cAAc,QAAQ,eAAe;AAE3C,QAAM,OAAO,kBAAkB,MAAM;AACrC,QAAM,YAAY,WAAW,OAAO,gBAAgB,IAAI,WAAW,EAAE,CAAC;AACtE,QAAM,OAAO,MAAM,SAAS;AAC5B,QAAM,SAAS,MAAM,iBAAiB,MAAM,IAAI;AAEhD,QAAM,WAAW,oBAAI,KAAK;AAC1B,QAAM,YAAY,IAAI,KAAK,SAAS,QAAQ,IAAI,MAAM,GAAI;AAC1D,QAAM,SAAyB;AAAA,IAC7B,OAAO,QAAQ;AAAA,IACf;AAAA,IACA;AAAA,IACA,UAAU,SAAS,YAAY;AAAA,IAC/B,WAAW,UAAU,YAAY;AAAA,IACjC;AAAA,IACA,UAAU;AAAA,EACZ;AAEA,QAAM,QAAQ,UAAU;AAAA,IACtB,IAAI,QAAQ;AAAA,IACZ;AAAA,IACA,UAAU,OAAO;AAAA,IACjB,WAAW,OAAO;AAAA,EACpB,CAAC;AAED,SAAO,EAAE,OAAO;AAClB;AAOA,eAAsB,OAAO,OAAe,QAA+C;AACzF,MAAI,OAAO,YAAY,OAAO,aAAa;AACzC,WAAO,EAAE,IAAI,OAAO,QAAQ,UAAU,mBAAmB,EAAE;AAAA,EAC7D;AACA,SAAO,YAAY;AAEnB,MAAI,IAAI,KAAK,OAAO,SAAS,EAAE,QAAQ,KAAK,KAAK,IAAI,GAAG;AACtD,WAAO,EAAE,IAAI,OAAO,QAAQ,WAAW,mBAAmB,OAAO,cAAc,OAAO,SAAS;AAAA,EACjG;AAEA,QAAM,SAAS,MAAM,iBAAiB,OAAO,OAAO,IAAI;AACxD,MAAI,CAAC,kBAAkB,QAAQ,OAAO,MAAM,GAAG;AAC7C,WAAO;AAAA,MACL,IAAI;AAAA,MACJ,QAAQ,OAAO,YAAY,OAAO,cAAc,WAAW;AAAA,MAC3D,mBAAmB,OAAO,cAAc,OAAO;AAAA,IACjD;AAAA,EACF;AACA,SAAO,EAAE,IAAI,MAAM,mBAAmB,OAAO,cAAc,OAAO,SAAS;AAC7E;AAIA,SAAS,kBAAkB,QAAwB;AAEjD,QAAM,MAAM,MAAM;AAClB,QAAM,QAAQ,IAAI,YAAY,CAAC;AAC/B,QAAM,YAAY,KAAK,MAAM,aAAa,GAAG,IAAI;AACjD,SAAO,MAAM;AACX,eAAW,OAAO,gBAAgB,KAAK;AACvC,QAAI,MAAM,CAAC,IAAK,WAAW;AACzB,cAAQ,MAAM,CAAC,IAAK,KAAK,SAAS,EAAE,SAAS,QAAQ,GAAG;AAAA,IAC1D;AAAA,EACF;AACF;AAEA,eAAe,iBAAiB,MAAc,SAAkC;AAC9E,QAAM,MAAM,IAAI,YAAY;AAC5B,QAAM,YAAY,IAAI,OAAO,IAAI;AACjC,QAAM,YAAY,QAAQ,OAAO;AACjC,QAAM,WAAW,IAAI,WAAW,UAAU,SAAS,UAAU,MAAM;AACnE,WAAS,IAAI,WAAW,CAAC;AACzB,WAAS,IAAI,WAAW,UAAU,MAAM;AACxC,QAAM,SAAS,MAAM,WAAW,OAAO,OAAO,OAAO,WAAW,QAAwB;AACxF,SAAO,MAAM,IAAI,WAAW,MAAM,CAAC;AACrC;AAEA,SAAS,kBAAkB,GAAW,GAAoB;AACxD,MAAI,EAAE,WAAW,EAAE,OAAQ,QAAO;AAClC,MAAI,OAAO;AACX,WAAS,IAAI,GAAG,IAAI,EAAE,QAAQ,KAAK;AACjC,YAAQ,EAAE,WAAW,CAAC,IAAI,EAAE,WAAW,CAAC;AAAA,EAC1C;AACA,SAAO,SAAS;AAClB;AAEA,SAAS,MAAM,OAA2B;AACxC,MAAI,MAAM;AACV,aAAW,KAAK,MAAO,QAAO,EAAE,SAAS,EAAE,EAAE,SAAS,GAAG,GAAG;AAC5D,SAAO;AACT;AAEA,SAAS,QAAQ,KAAyB;AACxC,QAAM,MAAM,IAAI,WAAW,IAAI,SAAS,CAAC;AACzC,WAAS,IAAI,GAAG,IAAI,IAAI,QAAQ,KAAK;AACnC,QAAI,CAAC,IAAI,OAAO,SAAS,IAAI,MAAM,IAAI,GAAG,IAAI,IAAI,CAAC,GAAG,EAAE;AAAA,EAC1D;AACA,SAAO;AACT;","names":[]}
@@ -0,0 +1,99 @@
1
+ /**
2
+ * **@noy-db/on-email-otp** — email OTP second factor.
3
+ *
4
+ * Issues short-lived numeric codes, delivers via a **caller-supplied
5
+ * transport** (SMTP / SES / Postmark / Resend / Mailgun / a test
6
+ * sink), and verifies them in constant time with a one-use burn
7
+ * guarantee.
8
+ *
9
+ * **Why no bundled SMTP client?** Every consumer already has an email
10
+ * sender in their stack — coupling noy-db to a specific SMTP library
11
+ * would pull in Node-only dependencies and complicate browser /
12
+ * edge-worker usage. The transport abstraction is a single-method
13
+ * interface; write a 5-line adapter for whatever you use today.
14
+ *
15
+ * ## Usage
16
+ *
17
+ * ```ts
18
+ * import { issue, verify } from '@noy-db/on-email-otp'
19
+ *
20
+ * // Challenge issue (server side, post-passphrase)
21
+ * const challenge = await issue({
22
+ * email: 'alice@example.com',
23
+ * ttlSeconds: 300,
24
+ * transport: async ({ to, code, expiresAt }) => {
25
+ * await smtp.send({ to, subject: 'Your code', text: `${code} — expires at ${expiresAt}` })
26
+ * },
27
+ * })
28
+ * // Store `challenge.record` somewhere the verifier can retrieve later.
29
+ *
30
+ * // Verify
31
+ * const ok = await verify('123456', challenge.record)
32
+ * ```
33
+ *
34
+ * ## Security model
35
+ *
36
+ * - Codes are 6-digit random numbers (by default) — 10⁶ space is
37
+ * fine because verification is rate-limited at the caller (by
38
+ * burning on success + a `maxAttempts` guard stored in the record).
39
+ * - The record carries `sha256(code + salt)` — the plaintext code is
40
+ * never written to storage. Verification hashes the input with the
41
+ * stored salt and compares in constant time.
42
+ * - `expiresAt` is enforced on every `verify()` — expired records are
43
+ * rejected before the hash comparison.
44
+ * - Burn-on-success is the caller's responsibility — delete the
45
+ * record after a successful verify returns.
46
+ *
47
+ * @packageDocumentation
48
+ */
49
+ interface EmailOtpTransportArgs {
50
+ readonly to: string;
51
+ readonly code: string;
52
+ readonly expiresAt: string;
53
+ readonly issuedAt: string;
54
+ }
55
+ type EmailOtpTransport = (args: EmailOtpTransportArgs) => Promise<void> | void;
56
+ interface IssueOptions {
57
+ readonly email: string;
58
+ /** Seconds before the challenge expires. Default 300 (5 min). */
59
+ readonly ttlSeconds?: number;
60
+ /** Code digits. Default 6. */
61
+ readonly digits?: 6 | 7 | 8;
62
+ /** Maximum failed attempts before the record should be burned. Default 5. */
63
+ readonly maxAttempts?: number;
64
+ /** Caller delivers the code here. Required. */
65
+ readonly transport: EmailOtpTransport;
66
+ }
67
+ interface EmailOtpRecord {
68
+ readonly email: string;
69
+ readonly digest: string;
70
+ readonly salt: string;
71
+ readonly issuedAt: string;
72
+ readonly expiresAt: string;
73
+ readonly maxAttempts: number;
74
+ attempts: number;
75
+ }
76
+ interface IssueResult {
77
+ readonly record: EmailOtpRecord;
78
+ }
79
+ interface VerifyResult {
80
+ readonly ok: boolean;
81
+ /**
82
+ * When `ok === false`, `reason` is one of:
83
+ * - `'expired'` — the record's `expiresAt` has passed.
84
+ * - `'mismatch'` — the digest didn't match.
85
+ * - `'locked'` — too many failed attempts; caller should burn.
86
+ */
87
+ readonly reason?: 'expired' | 'mismatch' | 'locked';
88
+ readonly remainingAttempts?: number;
89
+ }
90
+ /** Issue a new email OTP challenge. Calls the transport in the same tick. */
91
+ declare function issue(options: IssueOptions): Promise<IssueResult>;
92
+ /**
93
+ * Verify a user-entered code against a stored record. Increments
94
+ * `attempts` on every call (pass or fail). Caller should delete the
95
+ * record on success or when `remainingAttempts === 0`.
96
+ */
97
+ declare function verify(input: string, record: EmailOtpRecord): Promise<VerifyResult>;
98
+
99
+ export { type EmailOtpRecord, type EmailOtpTransport, type EmailOtpTransportArgs, type IssueOptions, type IssueResult, type VerifyResult, issue, verify };
@@ -0,0 +1,99 @@
1
+ /**
2
+ * **@noy-db/on-email-otp** — email OTP second factor.
3
+ *
4
+ * Issues short-lived numeric codes, delivers via a **caller-supplied
5
+ * transport** (SMTP / SES / Postmark / Resend / Mailgun / a test
6
+ * sink), and verifies them in constant time with a one-use burn
7
+ * guarantee.
8
+ *
9
+ * **Why no bundled SMTP client?** Every consumer already has an email
10
+ * sender in their stack — coupling noy-db to a specific SMTP library
11
+ * would pull in Node-only dependencies and complicate browser /
12
+ * edge-worker usage. The transport abstraction is a single-method
13
+ * interface; write a 5-line adapter for whatever you use today.
14
+ *
15
+ * ## Usage
16
+ *
17
+ * ```ts
18
+ * import { issue, verify } from '@noy-db/on-email-otp'
19
+ *
20
+ * // Challenge issue (server side, post-passphrase)
21
+ * const challenge = await issue({
22
+ * email: 'alice@example.com',
23
+ * ttlSeconds: 300,
24
+ * transport: async ({ to, code, expiresAt }) => {
25
+ * await smtp.send({ to, subject: 'Your code', text: `${code} — expires at ${expiresAt}` })
26
+ * },
27
+ * })
28
+ * // Store `challenge.record` somewhere the verifier can retrieve later.
29
+ *
30
+ * // Verify
31
+ * const ok = await verify('123456', challenge.record)
32
+ * ```
33
+ *
34
+ * ## Security model
35
+ *
36
+ * - Codes are 6-digit random numbers (by default) — 10⁶ space is
37
+ * fine because verification is rate-limited at the caller (by
38
+ * burning on success + a `maxAttempts` guard stored in the record).
39
+ * - The record carries `sha256(code + salt)` — the plaintext code is
40
+ * never written to storage. Verification hashes the input with the
41
+ * stored salt and compares in constant time.
42
+ * - `expiresAt` is enforced on every `verify()` — expired records are
43
+ * rejected before the hash comparison.
44
+ * - Burn-on-success is the caller's responsibility — delete the
45
+ * record after a successful verify returns.
46
+ *
47
+ * @packageDocumentation
48
+ */
49
+ interface EmailOtpTransportArgs {
50
+ readonly to: string;
51
+ readonly code: string;
52
+ readonly expiresAt: string;
53
+ readonly issuedAt: string;
54
+ }
55
+ type EmailOtpTransport = (args: EmailOtpTransportArgs) => Promise<void> | void;
56
+ interface IssueOptions {
57
+ readonly email: string;
58
+ /** Seconds before the challenge expires. Default 300 (5 min). */
59
+ readonly ttlSeconds?: number;
60
+ /** Code digits. Default 6. */
61
+ readonly digits?: 6 | 7 | 8;
62
+ /** Maximum failed attempts before the record should be burned. Default 5. */
63
+ readonly maxAttempts?: number;
64
+ /** Caller delivers the code here. Required. */
65
+ readonly transport: EmailOtpTransport;
66
+ }
67
+ interface EmailOtpRecord {
68
+ readonly email: string;
69
+ readonly digest: string;
70
+ readonly salt: string;
71
+ readonly issuedAt: string;
72
+ readonly expiresAt: string;
73
+ readonly maxAttempts: number;
74
+ attempts: number;
75
+ }
76
+ interface IssueResult {
77
+ readonly record: EmailOtpRecord;
78
+ }
79
+ interface VerifyResult {
80
+ readonly ok: boolean;
81
+ /**
82
+ * When `ok === false`, `reason` is one of:
83
+ * - `'expired'` — the record's `expiresAt` has passed.
84
+ * - `'mismatch'` — the digest didn't match.
85
+ * - `'locked'` — too many failed attempts; caller should burn.
86
+ */
87
+ readonly reason?: 'expired' | 'mismatch' | 'locked';
88
+ readonly remainingAttempts?: number;
89
+ }
90
+ /** Issue a new email OTP challenge. Calls the transport in the same tick. */
91
+ declare function issue(options: IssueOptions): Promise<IssueResult>;
92
+ /**
93
+ * Verify a user-entered code against a stored record. Increments
94
+ * `attempts` on every call (pass or fail). Caller should delete the
95
+ * record on success or when `remainingAttempts === 0`.
96
+ */
97
+ declare function verify(input: string, record: EmailOtpRecord): Promise<VerifyResult>;
98
+
99
+ export { type EmailOtpRecord, type EmailOtpTransport, type EmailOtpTransportArgs, type IssueOptions, type IssueResult, type VerifyResult, issue, verify };
package/dist/index.js ADDED
@@ -0,0 +1,92 @@
1
+ // src/index.ts
2
+ async function issue(options) {
3
+ const digits = options.digits ?? 6;
4
+ const ttl = options.ttlSeconds ?? 300;
5
+ const maxAttempts = options.maxAttempts ?? 5;
6
+ const code = randomNumericCode(digits);
7
+ const saltBytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
8
+ const salt = toHex(saltBytes);
9
+ const digest = await hashCodeWithSalt(code, salt);
10
+ const issuedAt = /* @__PURE__ */ new Date();
11
+ const expiresAt = new Date(issuedAt.getTime() + ttl * 1e3);
12
+ const record = {
13
+ email: options.email,
14
+ digest,
15
+ salt,
16
+ issuedAt: issuedAt.toISOString(),
17
+ expiresAt: expiresAt.toISOString(),
18
+ maxAttempts,
19
+ attempts: 0
20
+ };
21
+ await options.transport({
22
+ to: options.email,
23
+ code,
24
+ issuedAt: record.issuedAt,
25
+ expiresAt: record.expiresAt
26
+ });
27
+ return { record };
28
+ }
29
+ async function verify(input, record) {
30
+ if (record.attempts >= record.maxAttempts) {
31
+ return { ok: false, reason: "locked", remainingAttempts: 0 };
32
+ }
33
+ record.attempts += 1;
34
+ if (new Date(record.expiresAt).getTime() <= Date.now()) {
35
+ return { ok: false, reason: "expired", remainingAttempts: record.maxAttempts - record.attempts };
36
+ }
37
+ const digest = await hashCodeWithSalt(input, record.salt);
38
+ if (!constantTimeEqual(digest, record.digest)) {
39
+ return {
40
+ ok: false,
41
+ reason: record.attempts >= record.maxAttempts ? "locked" : "mismatch",
42
+ remainingAttempts: record.maxAttempts - record.attempts
43
+ };
44
+ }
45
+ return { ok: true, remainingAttempts: record.maxAttempts - record.attempts };
46
+ }
47
+ function randomNumericCode(digits) {
48
+ const max = 10 ** digits;
49
+ const bytes = new Uint32Array(1);
50
+ const threshold = Math.floor(4294967295 / max) * max;
51
+ while (true) {
52
+ globalThis.crypto.getRandomValues(bytes);
53
+ if (bytes[0] < threshold) {
54
+ return (bytes[0] % max).toString().padStart(digits, "0");
55
+ }
56
+ }
57
+ }
58
+ async function hashCodeWithSalt(code, saltHex) {
59
+ const enc = new TextEncoder();
60
+ const codeBytes = enc.encode(code);
61
+ const saltBytes = fromHex(saltHex);
62
+ const combined = new Uint8Array(codeBytes.length + saltBytes.length);
63
+ combined.set(codeBytes, 0);
64
+ combined.set(saltBytes, codeBytes.length);
65
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", combined);
66
+ return toHex(new Uint8Array(digest));
67
+ }
68
+ function constantTimeEqual(a, b) {
69
+ if (a.length !== b.length) return false;
70
+ let diff = 0;
71
+ for (let i = 0; i < a.length; i++) {
72
+ diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
73
+ }
74
+ return diff === 0;
75
+ }
76
+ function toHex(bytes) {
77
+ let out = "";
78
+ for (const b of bytes) out += b.toString(16).padStart(2, "0");
79
+ return out;
80
+ }
81
+ function fromHex(hex) {
82
+ const out = new Uint8Array(hex.length / 2);
83
+ for (let i = 0; i < out.length; i++) {
84
+ out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
85
+ }
86
+ return out;
87
+ }
88
+ export {
89
+ issue,
90
+ verify
91
+ };
92
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * **@noy-db/on-email-otp** — email OTP second factor.\n *\n * Issues short-lived numeric codes, delivers via a **caller-supplied\n * transport** (SMTP / SES / Postmark / Resend / Mailgun / a test\n * sink), and verifies them in constant time with a one-use burn\n * guarantee.\n *\n * **Why no bundled SMTP client?** Every consumer already has an email\n * sender in their stack — coupling noy-db to a specific SMTP library\n * would pull in Node-only dependencies and complicate browser /\n * edge-worker usage. The transport abstraction is a single-method\n * interface; write a 5-line adapter for whatever you use today.\n *\n * ## Usage\n *\n * ```ts\n * import { issue, verify } from '@noy-db/on-email-otp'\n *\n * // Challenge issue (server side, post-passphrase)\n * const challenge = await issue({\n * email: 'alice@example.com',\n * ttlSeconds: 300,\n * transport: async ({ to, code, expiresAt }) => {\n * await smtp.send({ to, subject: 'Your code', text: `${code} — expires at ${expiresAt}` })\n * },\n * })\n * // Store `challenge.record` somewhere the verifier can retrieve later.\n *\n * // Verify\n * const ok = await verify('123456', challenge.record)\n * ```\n *\n * ## Security model\n *\n * - Codes are 6-digit random numbers (by default) — 10⁶ space is\n * fine because verification is rate-limited at the caller (by\n * burning on success + a `maxAttempts` guard stored in the record).\n * - The record carries `sha256(code + salt)` — the plaintext code is\n * never written to storage. Verification hashes the input with the\n * stored salt and compares in constant time.\n * - `expiresAt` is enforced on every `verify()` — expired records are\n * rejected before the hash comparison.\n * - Burn-on-success is the caller's responsibility — delete the\n * record after a successful verify returns.\n *\n * @packageDocumentation\n */\n\nexport interface EmailOtpTransportArgs {\n readonly to: string\n readonly code: string\n readonly expiresAt: string\n readonly issuedAt: string\n}\n\nexport type EmailOtpTransport = (args: EmailOtpTransportArgs) => Promise<void> | void\n\nexport interface IssueOptions {\n readonly email: string\n /** Seconds before the challenge expires. Default 300 (5 min). */\n readonly ttlSeconds?: number\n /** Code digits. Default 6. */\n readonly digits?: 6 | 7 | 8\n /** Maximum failed attempts before the record should be burned. Default 5. */\n readonly maxAttempts?: number\n /** Caller delivers the code here. Required. */\n readonly transport: EmailOtpTransport\n}\n\nexport interface EmailOtpRecord {\n readonly email: string\n readonly digest: string // hex SHA-256(code ⊕ salt)\n readonly salt: string // hex\n readonly issuedAt: string\n readonly expiresAt: string\n readonly maxAttempts: number\n attempts: number\n}\n\nexport interface IssueResult {\n readonly record: EmailOtpRecord\n}\n\nexport interface VerifyResult {\n readonly ok: boolean\n /**\n * When `ok === false`, `reason` is one of:\n * - `'expired'` — the record's `expiresAt` has passed.\n * - `'mismatch'` — the digest didn't match.\n * - `'locked'` — too many failed attempts; caller should burn.\n */\n readonly reason?: 'expired' | 'mismatch' | 'locked'\n readonly remainingAttempts?: number\n}\n\n/** Issue a new email OTP challenge. Calls the transport in the same tick. */\nexport async function issue(options: IssueOptions): Promise<IssueResult> {\n const digits = options.digits ?? 6\n const ttl = options.ttlSeconds ?? 300\n const maxAttempts = options.maxAttempts ?? 5\n\n const code = randomNumericCode(digits)\n const saltBytes = globalThis.crypto.getRandomValues(new Uint8Array(16))\n const salt = toHex(saltBytes)\n const digest = await hashCodeWithSalt(code, salt)\n\n const issuedAt = new Date()\n const expiresAt = new Date(issuedAt.getTime() + ttl * 1000)\n const record: EmailOtpRecord = {\n email: options.email,\n digest,\n salt,\n issuedAt: issuedAt.toISOString(),\n expiresAt: expiresAt.toISOString(),\n maxAttempts,\n attempts: 0,\n }\n\n await options.transport({\n to: options.email,\n code,\n issuedAt: record.issuedAt,\n expiresAt: record.expiresAt,\n })\n\n return { record }\n}\n\n/**\n * Verify a user-entered code against a stored record. Increments\n * `attempts` on every call (pass or fail). Caller should delete the\n * record on success or when `remainingAttempts === 0`.\n */\nexport async function verify(input: string, record: EmailOtpRecord): Promise<VerifyResult> {\n if (record.attempts >= record.maxAttempts) {\n return { ok: false, reason: 'locked', remainingAttempts: 0 }\n }\n record.attempts += 1\n\n if (new Date(record.expiresAt).getTime() <= Date.now()) {\n return { ok: false, reason: 'expired', remainingAttempts: record.maxAttempts - record.attempts }\n }\n\n const digest = await hashCodeWithSalt(input, record.salt)\n if (!constantTimeEqual(digest, record.digest)) {\n return {\n ok: false,\n reason: record.attempts >= record.maxAttempts ? 'locked' : 'mismatch',\n remainingAttempts: record.maxAttempts - record.attempts,\n }\n }\n return { ok: true, remainingAttempts: record.maxAttempts - record.attempts }\n}\n\n// ── internals ─────────────────────────────────────────────────────────\n\nfunction randomNumericCode(digits: number): string {\n // Rejection sampling to avoid modulo bias.\n const max = 10 ** digits\n const bytes = new Uint32Array(1)\n const threshold = Math.floor(0xffffffff / max) * max\n while (true) {\n globalThis.crypto.getRandomValues(bytes)\n if (bytes[0]! < threshold) {\n return (bytes[0]! % max).toString().padStart(digits, '0')\n }\n }\n}\n\nasync function hashCodeWithSalt(code: string, saltHex: string): Promise<string> {\n const enc = new TextEncoder()\n const codeBytes = enc.encode(code)\n const saltBytes = fromHex(saltHex)\n const combined = new Uint8Array(codeBytes.length + saltBytes.length)\n combined.set(codeBytes, 0)\n combined.set(saltBytes, codeBytes.length)\n const digest = await globalThis.crypto.subtle.digest('SHA-256', combined as BufferSource)\n return toHex(new Uint8Array(digest))\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\nfunction toHex(bytes: Uint8Array): string {\n let out = ''\n for (const b of bytes) out += b.toString(16).padStart(2, '0')\n return out\n}\n\nfunction fromHex(hex: string): Uint8Array {\n const out = new Uint8Array(hex.length / 2)\n for (let i = 0; i < out.length; i++) {\n out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16)\n }\n return out\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@noy-db/on-email-otp",
3
+ "version": "0.1.0-pre.3",
4
+ "description": "Email OTP second factor for noy-db — generates time-boxed one-time codes, delivers via a user-supplied transport (SMTP / SES / Postmark / any fn), and verifies in constant time. 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-email-otp#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/vLannaAi/noy-db.git",
11
+ "directory": "packages/on-email-otp"
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-email-otp",
51
+ "email",
52
+ "otp",
53
+ "2fa",
54
+ "mfa"
55
+ ],
56
+ "publishConfig": {
57
+ "access": "public",
58
+ "tag": "latest"
59
+ },
60
+ "scripts": {
61
+ "build": "tsup",
62
+ "test": "vitest run",
63
+ "lint": "eslint src/",
64
+ "typecheck": "tsc --noEmit"
65
+ }
66
+ }