@noy-db/on-email-otp 0.2.0-pre.8 → 0.3.0-pre.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +6 -13
- package/dist/index.cjs +0 -118
- package/dist/index.cjs.map +0 -1
- package/dist/index.d.cts +0 -99
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ See the [main repository](https://github.com/vLannaAi/noy-db#readme) for setup,
|
|
|
26
26
|
|
|
27
27
|
- Source — [`packages/on-email-otp`](https://github.com/vLannaAi/noy-db/tree/main/packages/on-email-otp)
|
|
28
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)
|
|
29
|
+
- Spec — [`SPEC.md`](https://github.com/vLannaAi/noy-db-docs/blob/main/SPEC.md)
|
|
30
30
|
|
|
31
31
|
## License
|
|
32
32
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noy-db/on-email-otp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0-pre.1",
|
|
4
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
5
|
"license": "MIT",
|
|
6
6
|
"author": "vLannaAi <vicio@lanna.ai>",
|
|
@@ -17,17 +17,10 @@
|
|
|
17
17
|
"sideEffects": false,
|
|
18
18
|
"exports": {
|
|
19
19
|
".": {
|
|
20
|
-
"
|
|
21
|
-
|
|
22
|
-
"default": "./dist/index.js"
|
|
23
|
-
},
|
|
24
|
-
"require": {
|
|
25
|
-
"types": "./dist/index.d.cts",
|
|
26
|
-
"default": "./dist/index.cjs"
|
|
27
|
-
}
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"default": "./dist/index.js"
|
|
28
22
|
}
|
|
29
23
|
},
|
|
30
|
-
"main": "./dist/index.cjs",
|
|
31
24
|
"module": "./dist/index.js",
|
|
32
25
|
"types": "./dist/index.d.ts",
|
|
33
26
|
"files": [
|
|
@@ -36,14 +29,14 @@
|
|
|
36
29
|
"LICENSE"
|
|
37
30
|
],
|
|
38
31
|
"engines": {
|
|
39
|
-
"node": ">=
|
|
32
|
+
"node": ">=22.0.0"
|
|
40
33
|
},
|
|
41
34
|
"peerDependencies": {
|
|
42
|
-
"@noy-db/hub": "0.
|
|
35
|
+
"@noy-db/hub": "0.3.0-pre.1"
|
|
43
36
|
},
|
|
44
37
|
"devDependencies": {
|
|
45
38
|
"@types/node": "^22.0.0",
|
|
46
|
-
"@noy-db/hub": "0.
|
|
39
|
+
"@noy-db/hub": "0.3.0-pre.1"
|
|
47
40
|
},
|
|
48
41
|
"keywords": [
|
|
49
42
|
"noy-db",
|
package/dist/index.cjs
DELETED
|
@@ -1,118 +0,0 @@
|
|
|
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
|
package/dist/index.cjs.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
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/dist/index.d.cts
DELETED
|
@@ -1,99 +0,0 @@
|
|
|
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 };
|