@strav/captcha 0.4.31 → 1.0.0-alpha.43

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/src/registry.ts CHANGED
@@ -1,29 +1,33 @@
1
+ import { CaptchaError } from './captcha_error.ts'
1
2
  import type { ChallengeName, ChallengeType } from './types.ts'
2
3
 
3
- const registry = new Map<string, ChallengeType>()
4
-
5
4
  /**
6
- * Register a challenge type so `issueChallenge()` / `verifyChallenge()` can
7
- * dispatch to it. Built-ins (honeypot, pow, svg) self-register on first
8
- * import of `./challenges.ts` call this only when adding custom types.
9
- *
10
- * @example
11
- * registerChallengeType({
12
- * name: 'slider',
13
- * issue: () => ({ props: { ... }, answer: '...' }),
14
- * verify: (payload, response) => response === payload.ah ? { ok: true } : { ok: false, reason: 'answer_mismatch' },
15
- * })
5
+ * In-memory map of challenge name implementation. Owned by a
6
+ * `CaptchaManager` there's no module-level singleton in 1.x; each
7
+ * manager gets a fresh registry pre-loaded with the built-ins.
16
8
  */
17
- export function registerChallengeType(type: ChallengeType): void {
18
- registry.set(type.name, type)
19
- }
9
+ export class ChallengeRegistry {
10
+ private readonly entries = new Map<ChallengeName, ChallengeType>()
20
11
 
21
- /** Look up a registered challenge type. Returns `undefined` if not registered. */
22
- export function getChallengeType(name: ChallengeName): ChallengeType | undefined {
23
- return registry.get(name)
24
- }
12
+ register(type: ChallengeType): void {
13
+ this.entries.set(type.name, type)
14
+ }
15
+
16
+ get(name: ChallengeName): ChallengeType {
17
+ const type = this.entries.get(name)
18
+ if (!type) {
19
+ throw new CaptchaError(`Unknown captcha challenge type: ${String(name)}`, {
20
+ context: { name },
21
+ })
22
+ }
23
+ return type
24
+ }
25
+
26
+ has(name: ChallengeName): boolean {
27
+ return this.entries.has(name)
28
+ }
25
29
 
26
- /** All registered type names — used by validators and refresh route allow-listing. */
27
- export function listChallengeTypes(): ChallengeName[] {
28
- return Array.from(registry.keys())
30
+ list(): ChallengeName[] {
31
+ return Array.from(this.entries.keys())
32
+ }
29
33
  }
package/src/replay.ts ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Replay prevention — a thin wrapper over the cache `add` (put-if-absent)
3
+ * primitive. Splitting it from token-verification means failed attempts
4
+ * don't burn the replay slot: only a successful verify reaches here.
5
+ */
6
+
7
+ import type { Cache } from '@strav/cache'
8
+ import type { CaptchaTokenPayload } from './types.ts'
9
+
10
+ const REPLAY_PREFIX = 'captcha:used:'
11
+
12
+ /**
13
+ * Atomically mark a token as used. Returns `true` on the first successful
14
+ * use, `false` if the slot was already taken (replay).
15
+ *
16
+ * The cache TTL matches the token's remaining lifetime so dead replay
17
+ * keys don't accumulate. Once the token expires the signature path
18
+ * rejects it anyway, so a missed eviction has no security impact.
19
+ */
20
+ export async function consumeReplay(cache: Cache, payload: CaptchaTokenPayload): Promise<boolean> {
21
+ const key = REPLAY_PREFIX + payload.jti
22
+ const remainingMs = payload.iat + payload.exp * 60_000 - Date.now()
23
+ const ttlSeconds = Math.max(1, Math.ceil(remainingMs / 1000))
24
+ return cache.add(key, 1, ttlSeconds)
25
+ }
package/src/token.ts CHANGED
@@ -1,8 +1,15 @@
1
- import { encrypt } from '@strav/kernel'
2
- import type { CacheStore } from '@strav/kernel'
3
- import type { CaptchaTokenPayload, FailureReason } from './types.ts'
1
+ /**
2
+ * Token + answer-hash primitives.
3
+ *
4
+ * Tokens are HMAC-SHA256 signed JSON (`base64url(json).base64url(mac)`).
5
+ * The contents are not secret (an answer *hash* + salt + jti + difficulty)
6
+ * so encryption buys nothing extra over an integrity check. The signature
7
+ * stops the client from forging a token or tampering with difficulty,
8
+ * expiry, or the replay id.
9
+ */
4
10
 
5
- const REPLAY_PREFIX = 'captcha:used:'
11
+ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
12
+ import type { CaptchaTokenPayload, FailureReason } from './types.ts'
6
13
 
7
14
  /**
8
15
  * Hash an answer with its per-challenge salt. The salt prevents a global
@@ -10,59 +17,102 @@ const REPLAY_PREFIX = 'captcha:used:'
10
17
  * two challenges with the same plaintext answer produce different hashes.
11
18
  *
12
19
  * Lowercase + trim is the canonical normalization — matches what the
13
- * SVG/math verifiers do at compare time.
20
+ * SVG verifier does at compare time.
14
21
  */
15
22
  export function hashAnswer(answer: string, salt: string): string {
16
- return encrypt.sha256(answer.toLowerCase().trim() + salt)
23
+ return createHmac('sha256', salt).update(answer.toLowerCase().trim()).digest('hex')
17
24
  }
18
25
 
19
26
  /** Constant-time string equality on hex digests. */
20
27
  export function safeEqual(a: string, b: string): boolean {
21
28
  if (a.length !== b.length) return false
22
- let diff = 0
23
- for (let i = 0; i < a.length; i++) {
24
- diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
25
- }
26
- return diff === 0
29
+ const ab = Buffer.from(a)
30
+ const bb = Buffer.from(b)
31
+ return timingSafeEqual(ab, bb)
32
+ }
33
+
34
+ /** Cryptographically-strong random hex string of `nBytes` bytes (2× chars). */
35
+ export function randomHex(nBytes: number): string {
36
+ return randomBytes(nBytes).toString('hex')
37
+ }
38
+
39
+ const TEXT_ENCODER = new TextEncoder()
40
+
41
+ function b64urlEncode(bytes: Buffer | Uint8Array): string {
42
+ return Buffer.from(bytes).toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_')
43
+ }
44
+
45
+ function b64urlDecode(s: string): Buffer {
46
+ const pad = s.length % 4 === 0 ? '' : '='.repeat(4 - (s.length % 4))
47
+ return Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/') + pad, 'base64')
48
+ }
49
+
50
+ function normalizeSecret(secret: string | Uint8Array): Buffer {
51
+ if (typeof secret === 'string') return Buffer.from(secret, 'utf8')
52
+ return Buffer.from(secret)
27
53
  }
28
54
 
29
55
  /**
30
- * Seal a payload (sets iat/jti automatically). The salt and answer hash
31
- * must already be in `data` — the caller (`challenges.ts`) computes them
32
- * because only it knows the challenge type's normalize rules.
56
+ * Sign + encode a payload (sets `v`/`iat`/`jti` automatically). The salt
57
+ * and answer hash must already be in `data` — the caller (the manager)
58
+ * computes them because only it knows the challenge type's normalize
59
+ * rules.
33
60
  */
34
- export function sealToken(data: Omit<CaptchaTokenPayload, 'v' | 'iat' | 'jti'>): {
35
- token: string
36
- payload: CaptchaTokenPayload
37
- } {
61
+ export function signToken(
62
+ secret: string | Uint8Array,
63
+ data: Omit<CaptchaTokenPayload, 'v' | 'iat' | 'jti'>,
64
+ ): { token: string; payload: CaptchaTokenPayload } {
38
65
  const payload: CaptchaTokenPayload = {
39
66
  v: 1,
40
67
  iat: Date.now(),
41
- jti: encrypt.random(16),
68
+ jti: randomHex(16),
42
69
  ...data,
43
70
  }
44
- return { token: encrypt.seal(payload), payload }
71
+ const body = b64urlEncode(TEXT_ENCODER.encode(JSON.stringify(payload)))
72
+ const mac = b64urlEncode(createHmac('sha256', normalizeSecret(secret)).update(body).digest())
73
+ return { token: `${body}.${mac}`, payload }
45
74
  }
46
75
 
47
76
  /**
48
- * Decode a sealed token. Returns the payload, or a failure reason that
49
- * the verifier can short-circuit on (invalid seal, expired).
77
+ * Verify the signature and decode a token. Returns the payload, or a
78
+ * failure reason that the verifier can short-circuit on (invalid
79
+ * signature, expired).
50
80
  *
51
81
  * Replay prevention happens at `consumeReplay()` — splitting the steps
52
- * lets the verifier check the answer first and only burn the replay slot
53
- * on success.
82
+ * lets the verifier check the answer first and only burn the replay
83
+ * slot on success.
54
84
  */
55
- export function unsealToken(
56
- token: string
85
+ export function verifyToken(
86
+ secret: string | Uint8Array,
87
+ token: string,
57
88
  ): { ok: true; payload: CaptchaTokenPayload } | { ok: false; reason: FailureReason } {
89
+ const dot = token.indexOf('.')
90
+ if (dot <= 0 || dot === token.length - 1) return { ok: false, reason: 'token_invalid' }
91
+ const body = token.slice(0, dot)
92
+ const mac = token.slice(dot + 1)
93
+
94
+ const expected = b64urlEncode(createHmac('sha256', normalizeSecret(secret)).update(body).digest())
95
+ // Constant-time compare on equal-length strings.
96
+ if (expected.length !== mac.length || !safeEqual(expected, mac)) {
97
+ return { ok: false, reason: 'token_invalid' }
98
+ }
99
+
58
100
  let payload: CaptchaTokenPayload
59
101
  try {
60
- payload = encrypt.unseal<CaptchaTokenPayload>(token)
102
+ payload = JSON.parse(b64urlDecode(body).toString('utf8')) as CaptchaTokenPayload
61
103
  } catch {
62
104
  return { ok: false, reason: 'token_invalid' }
63
105
  }
64
106
 
65
- if (!payload || payload.v !== 1 || typeof payload.iat !== 'number') {
107
+ if (
108
+ !payload ||
109
+ payload.v !== 1 ||
110
+ typeof payload.iat !== 'number' ||
111
+ typeof payload.exp !== 'number' ||
112
+ typeof payload.jti !== 'string' ||
113
+ typeof payload.s !== 'string' ||
114
+ typeof payload.t !== 'string'
115
+ ) {
66
116
  return { ok: false, reason: 'token_invalid' }
67
117
  }
68
118
 
@@ -72,24 +122,3 @@ export function unsealToken(
72
122
 
73
123
  return { ok: true, payload }
74
124
  }
75
-
76
- /**
77
- * Mark a token as used. Returns false if the slot was already taken
78
- * (replay), true on the first successful use.
79
- *
80
- * The cache TTL matches the token's remaining lifetime so we don't keep
81
- * dead replay keys around. After expiry the token is rejected by
82
- * `unsealToken` regardless, so no leak.
83
- */
84
- export async function consumeReplay(
85
- store: CacheStore,
86
- payload: CaptchaTokenPayload
87
- ): Promise<boolean> {
88
- const key = REPLAY_PREFIX + payload.jti
89
- if (await store.has(key)) return false
90
-
91
- const remainingMs = payload.iat + payload.exp * 60_000 - Date.now()
92
- const ttlSeconds = Math.max(1, Math.ceil(remainingMs / 1000))
93
- await store.set(key, 1, ttlSeconds)
94
- return true
95
- }
package/src/types.ts CHANGED
@@ -1,12 +1,19 @@
1
- import type { CacheStore } from '@strav/kernel'
2
- import type { Middleware } from '@strav/http'
1
+ /**
2
+ * Public types for `@strav/captcha`.
3
+ *
4
+ * The token payload is **signed, not encrypted** — its contents are not
5
+ * secret (answer *hash* + salt + replay id + difficulty). HMAC over the
6
+ * payload is enough: the client cannot forge a token or tamper with
7
+ * difficulty/expiry without invalidating the signature.
8
+ */
3
9
 
4
- /** Built-in challenge type names. Extensible via `registerChallengeType()`. */
10
+ /** Built-in challenge type names. Extensible via `CaptchaManager.register()`. */
5
11
  export type ChallengeName = 'honeypot' | 'pow' | 'svg' | (string & {})
6
12
 
7
13
  /**
8
14
  * Reasons a CAPTCHA verification can fail. Surfaced to `onFailure` and
9
- * (when JSON) returned to the client so the form can display targeted help.
15
+ * (when JSON) returned to the client so the form can display targeted
16
+ * help.
10
17
  */
11
18
  export type FailureReason =
12
19
  | 'token_missing'
@@ -19,10 +26,11 @@ export type FailureReason =
19
26
  | 'unknown_type'
20
27
 
21
28
  /**
22
- * Sealed payload carried in the `_captcha` form field. Stateless — the
23
- * server reconstructs everything from this token at verify time. The salt
24
- * `s` makes brute-forcing the answer hash impractical even if the seal is
25
- * cracked; `jti` is the replay cache key so a token can only succeed once.
29
+ * Signed payload carried in the `_captcha` form field. Stateless — the
30
+ * server reconstructs everything from this token at verify time. The
31
+ * salt `s` makes brute-forcing the answer hash impractical even if the
32
+ * payload is leaked; `jti` is the replay cache key so a token can only
33
+ * succeed once.
26
34
  */
27
35
  export interface CaptchaTokenPayload {
28
36
  /** Schema version. Bump on breaking changes. */
@@ -43,9 +51,9 @@ export interface CaptchaTokenPayload {
43
51
  jti: string
44
52
  }
45
53
 
46
- /** What `issueChallenge()` hands back — token + render data for the form. */
54
+ /** What `issue()` hands back — token + render data for the form. */
47
55
  export interface IssuedChallenge {
48
- /** Sealed token to embed in a hidden form field. */
56
+ /** Signed token to embed in a hidden form field. */
49
57
  token: string
50
58
  /** Type-specific data the renderer needs (e.g. `{ challenge, difficulty }`). */
51
59
  props: Record<string, unknown>
@@ -53,7 +61,7 @@ export interface IssuedChallenge {
53
61
  html?: string
54
62
  }
55
63
 
56
- /** Options passed to `issueChallenge()`. */
64
+ /** Options passed to `issue()`. */
57
65
  export interface IssueOptions {
58
66
  /** Override default expiry. */
59
67
  ttlMinutes?: number
@@ -61,53 +69,56 @@ export interface IssueOptions {
61
69
  difficulty?: number
62
70
  }
63
71
 
64
- /** Result of `verifyChallenge()`. `ok: true` consumes the replay slot. */
72
+ /** Result of `verify()`. `ok: true` means the cryptographic checks passed; replay consumption is a separate step. */
65
73
  export type VerifyResult =
66
74
  | { ok: true; payload: CaptchaTokenPayload }
67
75
  | { ok: false; reason: FailureReason }
68
76
 
69
77
  /**
70
78
  * A challenge type plugged into the registry. `verify` receives the
71
- * decoded payload plus the user's response and the current cache, and
79
+ * decoded payload plus the user's response and the parsed body, and
72
80
  * returns either ok or a `FailureReason`.
73
81
  */
74
82
  export interface ChallengeType {
75
83
  name: ChallengeName
76
84
  /**
77
- * Build a fresh challenge. Returns the props the renderer needs and the
78
- * data that gets sealed into the token (answer hash + any type-specific
79
- * extras like PoW difficulty). Receives the per-challenge salt so types
80
- * like PoW can expose it to the client as the challenge value (PoW
81
- * verifies `sha256(payload.s + ':' + nonce)` so client and server need
82
- * to agree on the salt).
85
+ * Build a fresh challenge. Returns the props the renderer needs and
86
+ * the data that gets signed into the token (answer hash + any
87
+ * type-specific extras like PoW difficulty). Receives the
88
+ * per-challenge salt so types like PoW can expose it to the client as
89
+ * the challenge value (PoW verifies `sha256(payload.s + ':' + nonce)`
90
+ * so client and server need to agree on the salt).
83
91
  */
84
92
  issue(opts: IssueOptions & { salt: string }): {
85
93
  props: Record<string, unknown>
86
94
  /** Extra fields merged into the token payload (e.g. `{ d: difficulty }`). */
87
95
  extra?: Partial<Pick<CaptchaTokenPayload, 'd'>>
88
- /** Plaintext answer the user is expected to produce — hashed with salt before sealing. */
96
+ /** Plaintext answer the user is expected to produce — hashed with salt before signing. */
89
97
  answer?: string
90
98
  /** Optional pre-rendered HTML (e.g. SVG body). */
91
99
  html?: string
92
100
  }
93
101
  /**
94
- * Verify the response against the sealed payload. Pure function — replay
95
- * prevention happens upstream, not here.
102
+ * Verify the response against the signed payload. Pure function —
103
+ * replay prevention happens upstream, not here.
96
104
  */
97
105
  verify(
98
106
  payload: CaptchaTokenPayload,
99
107
  response: unknown,
100
- body: Record<string, unknown>
108
+ body: Record<string, unknown>,
101
109
  ): { ok: true } | { ok: false; reason: FailureReason }
102
110
  }
103
111
 
104
- /** Options for the `captcha()` middleware. */
105
- export interface CaptchaOptions {
106
- /** Challenge types accepted by this guard. @default ['honeypot', 'pow'] */
107
- types?: ChallengeName[]
112
+ /** Configuration consumed by `CaptchaManager` / `CaptchaProvider`. */
113
+ export interface CaptchaConfig {
114
+ /**
115
+ * HMAC signing secret. Required. Must be at least 32 bytes of entropy
116
+ * — a typical APP_KEY (32-byte hex or base64) is exactly right.
117
+ */
118
+ secret: string | Uint8Array
108
119
  /** Hidden field bots fill in. @default 'website' */
109
120
  honeypotField?: string
110
- /** Form field carrying the sealed token. @default '_captcha' */
121
+ /** Form field carrying the signed token. @default '_captcha' */
111
122
  tokenField?: string
112
123
  /** Form field carrying the user's answer / nonce. @default '_captcha_answer' */
113
124
  responseField?: string
@@ -115,17 +126,12 @@ export interface CaptchaOptions {
115
126
  difficulty?: number
116
127
  /** Token lifetime in minutes. @default 10 */
117
128
  ttlMinutes?: number
118
- /** Skip the guard for matching requests (e.g. internal health checks). */
119
- skip?: (ctx: Parameters<Middleware>[0]) => boolean
120
- /** Custom failure response. Falls back to JSON 422 / flash-redirect. */
121
- onFailure?: (
122
- ctx: Parameters<Middleware>[0],
123
- reason: FailureReason
124
- ) => Response | Promise<Response>
125
- /** Cache store for replay prevention. Defaults to `CacheManager.store`. */
126
- store?: CacheStore
127
129
  }
128
130
 
131
+ /**
132
+ * Frozen defaults — exposed so the view helper and the middleware
133
+ * agree on field names without re-declaring them.
134
+ */
129
135
  export const DEFAULTS = {
130
136
  honeypotField: 'website',
131
137
  tokenField: '_captcha',
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Form-fragment helper. Wire into the view engine via `config.view.globals`:
3
+ *
4
+ * import { makeCaptchaHelper } from '@strav/captcha/view'
5
+ *
6
+ * // config/view.ts
7
+ * export default {
8
+ * globals: { __captcha: makeCaptchaHelper(app.resolve(CaptchaManager)) },
9
+ * }
10
+ *
11
+ * Then in a template use `{{ raw __captcha('pow') }}` (or 'svg', or
12
+ * 'honeypot', or no arg for honeypot-only).
13
+ *
14
+ * Synchronous: `issue()` is sync, the renderer just concatenates
15
+ * strings — safe to call from inside the template render loop.
16
+ */
17
+
18
+ import type { CaptchaManager } from '../captcha_manager.ts'
19
+ import { CaptchaError } from '../captcha_error.ts'
20
+
21
+ export type CaptchaHelper = (variant?: 'honeypot' | 'pow' | 'svg') => string
22
+
23
+ export function makeCaptchaHelper(manager: CaptchaManager): CaptchaHelper {
24
+ return (variant?: 'honeypot' | 'pow' | 'svg') => {
25
+ const { honeypotField, tokenField, responseField } = manager.config
26
+ const honeypot = renderHoneypot(honeypotField)
27
+
28
+ if (!variant || variant === 'honeypot') return honeypot
29
+
30
+ if (variant === 'pow') {
31
+ const issued = manager.issue('pow')
32
+ const props = JSON.stringify({
33
+ ...issued.props,
34
+ tokenField,
35
+ responseField,
36
+ }).replace(/'/g, '&#39;')
37
+ return (
38
+ honeypot +
39
+ `<input type="hidden" name="${attr(tokenField)}" value="${attr(issued.token)}">` +
40
+ `<input type="hidden" name="${attr(responseField)}" value="">` +
41
+ `<div data-captcha="pow" data-props='${props}'></div>`
42
+ )
43
+ }
44
+
45
+ if (variant === 'svg') {
46
+ const issued = manager.issue('svg')
47
+ return (
48
+ honeypot +
49
+ `<input type="hidden" name="${attr(tokenField)}" value="${attr(issued.token)}">` +
50
+ `<div class="captcha-svg">${issued.html ?? ''}</div>` +
51
+ `<input type="text" name="${attr(responseField)}" autocomplete="off" ` +
52
+ `inputmode="latin" autocapitalize="characters" required aria-label="Type the characters shown">`
53
+ )
54
+ }
55
+
56
+ throw new CaptchaError(
57
+ `captchaHelper: unknown variant "${String(variant)}" (expected 'pow' | 'svg' | 'honeypot' or none).`,
58
+ { code: 'captcha.unknown-variant', context: { variant } },
59
+ )
60
+ }
61
+ }
62
+
63
+ function renderHoneypot(name: string): string {
64
+ // Triple defense: aria-hidden, tabindex=-1, off-screen via inline
65
+ // style. Don't use `display:none` — some bots skip those fields.
66
+ const n = attr(name)
67
+ return (
68
+ `<div aria-hidden="true" style="position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden">` +
69
+ `<label for="${n}">Leave this field empty</label>` +
70
+ `<input type="text" id="${n}" name="${n}" tabindex="-1" autocomplete="off" value="">` +
71
+ `</div>`
72
+ )
73
+ }
74
+
75
+ function attr(s: string): string {
76
+ return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
77
+ }
@@ -0,0 +1,3 @@
1
+ // `@strav/captcha/view` — form-fragment helper for view.globals.
2
+
3
+ export { type CaptchaHelper, makeCaptchaHelper } from './captcha_helper.ts'
package/src/challenges.ts DELETED
@@ -1,89 +0,0 @@
1
- import { encrypt } from '@strav/kernel'
2
- import type {
3
- ChallengeName,
4
- IssueOptions,
5
- IssuedChallenge,
6
- CaptchaTokenPayload,
7
- VerifyResult,
8
- } from './types.ts'
9
- import { DEFAULTS } from './types.ts'
10
- import { getChallengeType, registerChallengeType } from './registry.ts'
11
- import { sealToken, unsealToken, hashAnswer } from './token.ts'
12
- import { honeypotChallenge } from './challenges/honeypot.ts'
13
- import { powChallenge } from './challenges/pow.ts'
14
- import { svgChallenge } from './challenges/svg.ts'
15
-
16
- // Self-register built-ins on first import. Side-effect on import is the
17
- // same pattern the auth/flag drivers use — single source of truth, no
18
- // service-locator wiring required.
19
- registerChallengeType(honeypotChallenge)
20
- registerChallengeType(powChallenge)
21
- registerChallengeType(svgChallenge)
22
-
23
- /**
24
- * Issue a fresh challenge. Synchronous — `encrypt.seal` and the built-in
25
- * issuers all run without I/O. Caller embeds `token` in a hidden field
26
- * and uses `props` (and `html` for SVG) to render the challenge.
27
- */
28
- export function issueChallenge(type: ChallengeName, opts: IssueOptions = {}): IssuedChallenge {
29
- const challenge = getChallengeType(type)
30
- if (!challenge) throw new Error(`Unknown captcha challenge type: ${type}`)
31
-
32
- const ttlMinutes = opts.ttlMinutes ?? DEFAULTS.ttlMinutes
33
- // 16 bytes (32 hex chars) — salts double as the PoW challenge value, so
34
- // give them enough entropy that a precomputed PoW table is impractical.
35
- const salt = encrypt.random(16)
36
-
37
- const issued = challenge.issue({
38
- ...opts,
39
- difficulty: opts.difficulty ?? DEFAULTS.difficulty,
40
- salt,
41
- })
42
-
43
- const tokenData: Omit<CaptchaTokenPayload, 'v' | 'iat' | 'jti'> = {
44
- t: type,
45
- s: salt,
46
- exp: ttlMinutes,
47
- ...(issued.answer !== undefined ? { ah: hashAnswer(issued.answer, salt) } : {}),
48
- ...(issued.extra ?? {}),
49
- }
50
-
51
- const { token } = sealToken(tokenData)
52
-
53
- return {
54
- token,
55
- props: issued.props,
56
- ...(issued.html !== undefined ? { html: issued.html } : {}),
57
- }
58
- }
59
-
60
- /**
61
- * Decode and verify a token + the user's response. Replay prevention
62
- * happens upstream in the middleware (after we know verification passed)
63
- * so that failed attempts don't burn the replay slot.
64
- *
65
- * Pure: no cache writes, no I/O. Easy to call from a custom validator
66
- * or a JSON API handler.
67
- */
68
- export function verifyChallenge(
69
- token: string,
70
- response: unknown,
71
- body: Record<string, unknown> = {}
72
- ): VerifyResult {
73
- if (!token || typeof token !== 'string') {
74
- return { ok: false, reason: 'token_missing' }
75
- }
76
-
77
- const decoded = unsealToken(token)
78
- if (!decoded.ok) return decoded
79
-
80
- const challenge = getChallengeType(decoded.payload.t)
81
- if (!challenge) return { ok: false, reason: 'unknown_type' }
82
-
83
- const result = challenge.verify(decoded.payload, response, body)
84
- if (!result.ok) return result
85
-
86
- return { ok: true, payload: decoded.payload }
87
- }
88
-
89
- export { honeypotChallenge, powChallenge, svgChallenge }