@noy-db/on-totp 0.9.0 → 0.10.0-pre.2

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/dist/index.d.ts CHANGED
@@ -35,10 +35,22 @@
35
35
  * const uri = totpProvisioningUri(secret, { account: 'alice@acme.com', issuer: 'Acme' })
36
36
  * // Show QR code of `uri`.
37
37
  *
38
- * // Unlock
39
- * const ok = await verifyTotp(secret, userEnteredCode)
38
+ * // Unlock — refuse replays (RFC 6238 §5.2): persist the accepted step
39
+ * const step = await verifyTotpStep(secret, userEnteredCode, { lastUsedStep })
40
+ * if (step === null) throw new Error('bad or reused code')
41
+ * lastUsedStep = step // store it before treating the factor as passed
40
42
  * ```
41
43
  *
44
+ * ## ⛔ Replay is the CALLER's to prevent
45
+ *
46
+ * This package is stateless. A code verifies for every step inside the
47
+ * drift window (±1 by default, so ~90 s), so a shoulder-surfed or
48
+ * phished code works again until it ages out — unless the caller records
49
+ * the step `verifyTotpStep` returns and passes it back as `lastUsedStep`,
50
+ * which refuses that step and every earlier one. Store it per secret, and
51
+ * where the party presenting codes cannot rewind it: a `lastUsedStep` an
52
+ * attacker can reset is no protection.
53
+ *
42
54
  * @packageDocumentation
43
55
  */
44
56
  /** Encode raw bytes as RFC 4648 Base32 (no padding — authenticator apps accept this). */
@@ -75,13 +87,30 @@ interface VerifyTotpOptions extends TotpOptions {
75
87
  readonly window?: number;
76
88
  /** Override "now" for deterministic tests. */
77
89
  readonly timestamp?: number;
90
+ /**
91
+ * The step of the last code accepted for this secret, as returned by
92
+ * `verifyTotpStep`. Codes for this step or any earlier one are refused —
93
+ * RFC 6238 §5.2 replay prevention (family#129). Omitted, nothing is
94
+ * refused, and replay within the window is possible.
95
+ */
96
+ readonly lastUsedStep?: number;
78
97
  }
79
98
  /** Compute the TOTP code for the given secret at the current time. */
80
99
  declare function generateTotpCode(secret: string, options?: TotpOptions): Promise<string>;
81
100
  /**
82
101
  * Verify a user-entered code against the secret. Constant-time comparison;
83
- * accepts drift within `window` steps.
102
+ * accepts drift within `window` steps. Refuses steps at or before
103
+ * `options.lastUsedStep`.
104
+ *
105
+ * ⚠️ A boolean cannot tell the caller WHICH step matched, so it cannot feed
106
+ * replay prevention on its own — use `verifyTotpStep` for that.
84
107
  */
85
108
  declare function verifyTotp(secret: string, code: string, options?: VerifyTotpOptions): Promise<boolean>;
109
+ /**
110
+ * Verify a code and return the step it matched, or `null`. Persist the
111
+ * returned step and pass it back as `lastUsedStep` on the next call to
112
+ * refuse a replay (RFC 6238 §5.2, family#129).
113
+ */
114
+ declare function verifyTotpStep(secret: string, code: string, options?: VerifyTotpOptions): Promise<number | null>;
86
115
 
87
- export { type TotpOptions, type TotpProvisioningUriOptions, type VerifyTotpOptions, decodeBase32, encodeBase32, generateTotpCode, generateTotpSecret, totpProvisioningUri, verifyTotp };
116
+ export { type TotpOptions, type TotpProvisioningUriOptions, type VerifyTotpOptions, decodeBase32, encodeBase32, generateTotpCode, generateTotpSecret, totpProvisioningUri, verifyTotp, verifyTotpStep };
package/dist/index.js CHANGED
@@ -55,16 +55,20 @@ async function generateTotpCode(secret, options = {}) {
55
55
  return codeAt(secret, now, options);
56
56
  }
57
57
  async function verifyTotp(secret, code, options = {}) {
58
+ return await verifyTotpStep(secret, code, options) !== null;
59
+ }
60
+ async function verifyTotpStep(secret, code, options = {}) {
58
61
  const digits = options.digits ?? 6;
59
- if (code.length !== digits) return false;
62
+ if (code.length !== digits) return null;
60
63
  const window = options.window ?? 1;
61
64
  const now = options.timestamp ?? Math.floor(Date.now() / 1e3);
62
65
  const period = options.period ?? 30;
63
66
  const step = Math.floor(now / period);
64
- let matched = false;
67
+ const floor = options.lastUsedStep ?? -Infinity;
68
+ let matched = null;
65
69
  for (let i = -window; i <= window; i++) {
66
70
  const candidate = await codeAtStep(secret, step + i, options);
67
- if (constantTimeEqual(candidate, code)) matched = true;
71
+ if (constantTimeEqual(candidate, code) && step + i > floor) matched = step + i;
68
72
  }
69
73
  return matched;
70
74
  }
@@ -113,6 +117,7 @@ export {
113
117
  generateTotpCode,
114
118
  generateTotpSecret,
115
119
  totpProvisioningUri,
116
- verifyTotp
120
+ verifyTotp,
121
+ verifyTotpStep
117
122
  };
118
123
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +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 secret (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 secret → 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 secret-KEK compromise. For real\n * hardware-backed second factor, use `@noy-db/on-webauthn`.\n *\n * ## API\n *\n * ```ts\n * import { generateTotpSecret, totpProvisioningUri, verifyTotp, generateTotpCode } from '@noy-db/on-totp'\n *\n * // Enroll\n * const secret = generateTotpSecret()\n * const uri = totpProvisioningUri(secret, { account: 'alice@acme.com', issuer: 'Acme' })\n * // Show QR code of `uri`.\n *\n * // Unlock\n * const ok = await verifyTotp(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 generateTotpSecret(): string {\n const bytes = globalThis.crypto.getRandomValues(new Uint8Array(20))\n return encodeBase32(bytes)\n}\n\n// ─── Provisioning URI ───────────────────────────────────────────────────\n\nexport interface TotpProvisioningUriOptions {\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 totpProvisioningUri(secret: string, options: TotpProvisioningUriOptions): 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 VerifyTotpOptions 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 generateTotpCode(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 verifyTotp(secret: string, code: string, options: VerifyTotpOptions = {}): 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),\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":";AA8CA,IAAM,kBAAkB;AAGjB,SAAS,aAAa,OAA2B;AACtD,MAAI,MAAM;AACV,MAAI,MAAM;AACV,MAAI,OAAO;AACX,aAAW,KAAK,OAAO;AACrB,UAAO,OAAO,IAAK;AACnB,YAAQ;AACR,WAAO,QAAQ,GAAG;AAChB,cAAQ;AACR,aAAO,gBAAiB,OAAO,OAAQ,EAAI;AAAA,IAC7C;AAAA,EACF;AACA,MAAI,OAAO,GAAG;AACZ,WAAO,gBAAiB,OAAQ,IAAI,OAAS,EAAI;AAAA,EACnD;AACA,SAAO;AACT;AAGO,SAAS,aAAa,OAA2B;AACtD,QAAM,UAAU,MAAM,QAAQ,SAAS,EAAE,EAAE,YAAY;AACvD,QAAM,QAAkB,CAAC;AACzB,MAAI,MAAM;AACV,MAAI,OAAO;AACX,aAAW,MAAM,SAAS;AACxB,UAAM,IAAI,gBAAgB,QAAQ,EAAE;AACpC,QAAI,IAAI,EAAG,OAAM,IAAI,MAAM,8BAA8B,EAAE,GAAG;AAC9D,UAAO,OAAO,IAAK;AACnB,YAAQ;AACR,QAAI,QAAQ,GAAG;AACb,cAAQ;AACR,YAAM,KAAM,OAAO,OAAQ,GAAI;AAAA,IACjC;AAAA,EACF;AACA,SAAO,IAAI,WAAW,KAAK;AAC7B;AAKO,SAAS,qBAA6B;AAC3C,QAAM,QAAQ,WAAW,OAAO,gBAAgB,IAAI,WAAW,EAAE,CAAC;AAClE,SAAO,aAAa,KAAK;AAC3B;AAkBO,SAAS,oBAAoB,QAAgB,SAA6C;AAC/F,QAAM,SAAS,QAAQ;AACvB,QAAM,QAAQ,SACV,GAAG,mBAAmB,MAAM,CAAC,IAAI,mBAAmB,QAAQ,OAAO,CAAC,KACpE,mBAAmB,QAAQ,OAAO;AAEtC,QAAM,SAAS,IAAI,gBAAgB;AAAA,IACjC;AAAA,IACA,WAAW,QAAQ,aAAa;AAAA,IAChC,QAAQ,OAAO,QAAQ,UAAU,CAAC;AAAA,IAClC,QAAQ,OAAO,QAAQ,UAAU,EAAE;AAAA,EACrC,CAAC;AACD,MAAI,OAAQ,QAAO,IAAI,UAAU,MAAM;AAEvC,SAAO,kBAAkB,KAAK,IAAI,OAAO,SAAS,CAAC;AACrD;AAsBA,eAAsB,iBAAiB,QAAgB,UAAuB,CAAC,GAAoB;AACjG,QAAM,MAAM,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI;AACxC,SAAO,OAAO,QAAQ,KAAK,OAAO;AACpC;AAMA,eAAsB,WAAW,QAAgB,MAAc,UAA6B,CAAC,GAAqB;AAChH,QAAM,SAAS,QAAQ,UAAU;AACjC,MAAI,KAAK,WAAW,OAAQ,QAAO;AACnC,QAAM,SAAS,QAAQ,UAAU;AACjC,QAAM,MAAM,QAAQ,aAAa,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI;AAC7D,QAAM,SAAS,QAAQ,UAAU;AACjC,QAAM,OAAO,KAAK,MAAM,MAAM,MAAM;AAIpC,MAAI,UAAU;AACd,WAAS,IAAI,CAAC,QAAQ,KAAK,QAAQ,KAAK;AACtC,UAAM,YAAY,MAAM,WAAW,QAAQ,OAAO,GAAG,OAAO;AAC5D,QAAI,kBAAkB,WAAW,IAAI,EAAG,WAAU;AAAA,EACpD;AACA,SAAO;AACT;AAEA,eAAe,OAAO,QAAgB,WAAmB,SAAuC;AAC9F,QAAM,SAAS,QAAQ,UAAU;AACjC,SAAO,WAAW,QAAQ,KAAK,MAAM,YAAY,MAAM,GAAG,OAAO;AACnE;AAEA,eAAe,WAAW,QAAgB,MAAc,SAAuC;AAC7F,QAAM,SAAS,QAAQ,UAAU;AACjC,QAAM,YAAY,QAAQ,aAAa;AAEvC,QAAM,WAAW,aAAa,MAAM;AAEpC,QAAM,UAAU,IAAI,WAAW,CAAC;AAChC,MAAI,IAAI;AACR,WAAS,IAAI,GAAG,KAAK,GAAG,KAAK;AAC3B,YAAQ,CAAC,IAAI,IAAI;AACjB,QAAI,KAAK,MAAM,IAAI,GAAG;AAAA,EACxB;AAEA,QAAM,WAAW,cAAc,SAAS,UAAU,cAAc,WAAW,YAAY;AACvF,QAAM,YAAY,MAAM,WAAW,OAAO,OAAO;AAAA,IAC/C;AAAA,IACA;AAAA,IACA,EAAE,MAAM,QAAQ,MAAM,SAAS;AAAA,IAC/B;AAAA,IACA,CAAC,MAAM;AAAA,EACT;AACA,QAAM,YAAY,IAAI;AAAA,IACpB,MAAM,WAAW,OAAO,OAAO,KAAK,QAAQ,WAAW,OAAO;AAAA,EAChE;AAGA,QAAM,SAAS,UAAU,UAAU,SAAS,CAAC,IAAK;AAClD,QAAM,UACF,UAAU,MAAM,IAAK,QAAS,MAC9B,UAAU,SAAS,CAAC,IAAK,QAAS,MAClC,UAAU,SAAS,CAAC,IAAK,QAAS,IACnC,UAAU,SAAS,CAAC,IAAK;AAE5B,QAAM,UAAU,MAAM;AACtB,QAAM,OAAO,SAAS,SAAS,SAAS,EAAE,SAAS,QAAQ,GAAG;AAC9D,SAAO;AACT;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;","names":[]}
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 secret (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 secret → 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 secret-KEK compromise. For real\n * hardware-backed second factor, use `@noy-db/on-webauthn`.\n *\n * ## API\n *\n * ```ts\n * import { generateTotpSecret, totpProvisioningUri, verifyTotp, generateTotpCode } from '@noy-db/on-totp'\n *\n * // Enroll\n * const secret = generateTotpSecret()\n * const uri = totpProvisioningUri(secret, { account: 'alice@acme.com', issuer: 'Acme' })\n * // Show QR code of `uri`.\n *\n * // Unlock — refuse replays (RFC 6238 §5.2): persist the accepted step\n * const step = await verifyTotpStep(secret, userEnteredCode, { lastUsedStep })\n * if (step === null) throw new Error('bad or reused code')\n * lastUsedStep = step // store it before treating the factor as passed\n * ```\n *\n * ## ⛔ Replay is the CALLER's to prevent\n *\n * This package is stateless. A code verifies for every step inside the\n * drift window (±1 by default, so ~90 s), so a shoulder-surfed or\n * phished code works again until it ages out — unless the caller records\n * the step `verifyTotpStep` returns and passes it back as `lastUsedStep`,\n * which refuses that step and every earlier one. Store it per secret, and\n * where the party presenting codes cannot rewind it: a `lastUsedStep` an\n * attacker can reset is no protection.\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 generateTotpSecret(): string {\n const bytes = globalThis.crypto.getRandomValues(new Uint8Array(20))\n return encodeBase32(bytes)\n}\n\n// ─── Provisioning URI ───────────────────────────────────────────────────\n\nexport interface TotpProvisioningUriOptions {\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 totpProvisioningUri(secret: string, options: TotpProvisioningUriOptions): 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 VerifyTotpOptions 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 * The step of the last code accepted for this secret, as returned by\n * `verifyTotpStep`. Codes for this step or any earlier one are refused —\n * RFC 6238 §5.2 replay prevention (family#129). Omitted, nothing is\n * refused, and replay within the window is possible.\n */\n readonly lastUsedStep?: number\n}\n\n/** Compute the TOTP code for the given secret at the current time. */\nexport async function generateTotpCode(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. Refuses steps at or before\n * `options.lastUsedStep`.\n *\n * ⚠️ A boolean cannot tell the caller WHICH step matched, so it cannot feed\n * replay prevention on its own — use `verifyTotpStep` for that.\n */\nexport async function verifyTotp(secret: string, code: string, options: VerifyTotpOptions = {}): Promise<boolean> {\n return (await verifyTotpStep(secret, code, options)) !== null\n}\n\n/**\n * Verify a code and return the step it matched, or `null`. Persist the\n * returned step and pass it back as `lastUsedStep` on the next call to\n * refuse a replay (RFC 6238 §5.2, family#129).\n */\nexport async function verifyTotpStep(secret: string, code: string, options: VerifyTotpOptions = {}): Promise<number | null> {\n const digits = options.digits ?? 6\n if (code.length !== digits) return null\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 const floor = options.lastUsedStep ?? -Infinity\n\n // Gather the candidate codes and do constant-time equality — avoids the\n // wall-clock timing oracle on mismatches. Every candidate is computed,\n // refused steps included; only the match is masked.\n let matched: number | null = null\n for (let i = -window; i <= window; i++) {\n const candidate = await codeAtStep(secret, step + i, options)\n if (constantTimeEqual(candidate, code) && step + i > floor) matched = step + i\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),\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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noy-db/on-totp",
3
- "version": "0.9.0",
3
+ "version": "0.10.0-pre.2",
4
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
5
  "license": "Apache-2.0",
6
6
  "author": "vLannaAi <vicio@lanna.ai>",