@strav/captcha 0.4.31 → 1.0.0-alpha.42
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 +9 -0
- package/package.json +29 -20
- package/src/captcha_error.ts +14 -0
- package/src/captcha_manager.ts +132 -0
- package/src/captcha_provider.ts +49 -0
- package/src/challenges/{honeypot.ts → honeypot_challenge.ts} +6 -6
- package/src/challenges/{pow.ts → pow_challenge.ts} +7 -7
- package/src/challenges/{svg.ts → svg_challenge.ts} +8 -11
- package/src/http/captcha_middleware.ts +99 -0
- package/src/http/captcha_rule.ts +49 -0
- package/src/http/index.ts +5 -0
- package/src/http/refresh_routes.ts +59 -0
- package/src/index.ts +25 -42
- package/src/registry.ts +26 -22
- package/src/replay.ts +25 -0
- package/src/token.ts +78 -49
- package/src/types.ts +43 -37
- package/src/view/captcha_helper.ts +77 -0
- package/src/view/index.ts +3 -0
- package/src/challenges.ts +0 -89
- package/src/islands/pow.vue +0 -109
- package/src/islands/refresh.vue +0 -43
- package/src/middleware.ts +0 -125
- package/src/route.ts +0 -73
- package/src/validation_rule.ts +0 -31
- package/src/view_helper.ts +0 -105
- package/tsconfig.json +0 -5
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
18
|
-
|
|
19
|
-
}
|
|
9
|
+
export class ChallengeRegistry {
|
|
10
|
+
private readonly entries = new Map<ChallengeName, ChallengeType>()
|
|
20
11
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
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
|
|
20
|
+
* SVG verifier does at compare time.
|
|
14
21
|
*/
|
|
15
22
|
export function hashAnswer(answer: string, salt: string): string {
|
|
16
|
-
return
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
*
|
|
31
|
-
* must already be in `data` — the caller (
|
|
32
|
-
* because only it knows the challenge type's normalize
|
|
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
|
|
35
|
-
|
|
36
|
-
|
|
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:
|
|
68
|
+
jti: randomHex(16),
|
|
42
69
|
...data,
|
|
43
70
|
}
|
|
44
|
-
|
|
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
|
-
*
|
|
49
|
-
* the verifier can short-circuit on (invalid
|
|
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
|
|
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
|
|
56
|
-
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
2
|
-
|
|
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 `
|
|
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
|
|
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
|
-
*
|
|
23
|
-
* server reconstructs everything from this token at verify time. The
|
|
24
|
-
* `s` makes brute-forcing the answer hash impractical even if the
|
|
25
|
-
*
|
|
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 `
|
|
54
|
+
/** What `issue()` hands back — token + render data for the form. */
|
|
47
55
|
export interface IssuedChallenge {
|
|
48
|
-
/**
|
|
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 `
|
|
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 `
|
|
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
|
|
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
|
|
78
|
-
* data that gets
|
|
79
|
-
* extras like PoW difficulty). Receives the
|
|
80
|
-
* like PoW can expose it to the client as
|
|
81
|
-
* verifies `sha256(payload.s + ':' + nonce)`
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
105
|
-
export interface
|
|
106
|
-
/**
|
|
107
|
-
|
|
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
|
|
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, ''')
|
|
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, '&').replace(/"/g, '"').replace(/</g, '<')
|
|
77
|
+
}
|
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 }
|