@strav/captcha 0.4.10
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/package.json +31 -0
- package/src/challenges/honeypot.ts +24 -0
- package/src/challenges/pow.ts +63 -0
- package/src/challenges/svg.ts +113 -0
- package/src/challenges.ts +89 -0
- package/src/index.ts +44 -0
- package/src/islands/pow.vue +109 -0
- package/src/islands/refresh.vue +43 -0
- package/src/middleware.ts +125 -0
- package/src/registry.ts +29 -0
- package/src/route.ts +73 -0
- package/src/token.ts +95 -0
- package/src/types.ts +135 -0
- package/src/validation_rule.ts +31 -0
- package/src/view_helper.ts +105 -0
- package/tsconfig.json +5 -0
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@strav/captcha",
|
|
3
|
+
"version": "0.4.10",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Built-in CAPTCHA for the Strav framework — honeypot, proof-of-work, and SVG text challenges with no external dependencies",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./*": "./src/*.ts"
|
|
10
|
+
},
|
|
11
|
+
"strav": {
|
|
12
|
+
"islands": {
|
|
13
|
+
"namespace": "captcha",
|
|
14
|
+
"dir": "./src/islands"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"src/",
|
|
19
|
+
"package.json",
|
|
20
|
+
"tsconfig.json"
|
|
21
|
+
],
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"@strav/kernel": "0.4.10",
|
|
24
|
+
"@strav/http": "0.4.10",
|
|
25
|
+
"@strav/view": "0.4.10"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "bun test tests/",
|
|
29
|
+
"typecheck": "tsc --noEmit"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ChallengeType } from '../types.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Invisible-field honeypot. The middleware injects a hidden input that
|
|
5
|
+
* humans never see; bots that auto-fill every field on the page will
|
|
6
|
+
* trip it. Free, zero UX, layer it under everything else.
|
|
7
|
+
*
|
|
8
|
+
* The honeypot field name is checked at the middleware layer — this
|
|
9
|
+
* type just establishes the token shape so the protection still
|
|
10
|
+
* surfaces a `pow_insufficient`/`token_*` error path consistent with
|
|
11
|
+
* other types when the form is submitted without a token.
|
|
12
|
+
*/
|
|
13
|
+
export const honeypotChallenge: ChallengeType = {
|
|
14
|
+
name: 'honeypot',
|
|
15
|
+
issue() {
|
|
16
|
+
return { props: {} }
|
|
17
|
+
},
|
|
18
|
+
// Honeypot's "answer" is the absence of a value in the trap field. The
|
|
19
|
+
// middleware checks `body[honeypotField]` directly before getting here,
|
|
20
|
+
// so by the time we run the body has already passed the trap.
|
|
21
|
+
verify() {
|
|
22
|
+
return { ok: true }
|
|
23
|
+
},
|
|
24
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { encrypt } from '@strav/kernel'
|
|
2
|
+
import type { ChallengeType } from '../types.ts'
|
|
3
|
+
import { DEFAULTS } from '../types.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Hashcash-style proof of work. The client must find a nonce such that
|
|
7
|
+
* `sha256(salt + ':' + nonce)` has at least `difficulty` leading zero
|
|
8
|
+
* bits. Verification is a single sha256 — no DB, no chain of trust.
|
|
9
|
+
*
|
|
10
|
+
* The salt IS the challenge — exposed in props so the client can hash
|
|
11
|
+
* against the same value the server will check. No separate `answer`:
|
|
12
|
+
* the proof IS the work, not a secret.
|
|
13
|
+
*
|
|
14
|
+
* Difficulty 18 ≈ 100–500 ms on a modern laptop; halving (16) makes it
|
|
15
|
+
* comfortable on phones, +2 (20) is a noticeable pause.
|
|
16
|
+
*/
|
|
17
|
+
export const powChallenge: ChallengeType = {
|
|
18
|
+
name: 'pow',
|
|
19
|
+
issue(opts) {
|
|
20
|
+
const difficulty = opts.difficulty ?? DEFAULTS.difficulty
|
|
21
|
+
return {
|
|
22
|
+
props: { challenge: opts.salt, difficulty },
|
|
23
|
+
extra: { d: difficulty },
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
verify(payload, response) {
|
|
27
|
+
if (typeof response !== 'string' || response.length === 0) {
|
|
28
|
+
return { ok: false, reason: 'pow_insufficient' }
|
|
29
|
+
}
|
|
30
|
+
const difficulty = payload.d ?? DEFAULTS.difficulty
|
|
31
|
+
|
|
32
|
+
// Cap nonce length so a malicious client can't ship megabyte payloads
|
|
33
|
+
// through our hash function.
|
|
34
|
+
if (response.length > 64) return { ok: false, reason: 'pow_insufficient' }
|
|
35
|
+
|
|
36
|
+
const digest = encrypt.sha256(payload.s + ':' + response)
|
|
37
|
+
if (countLeadingZeroBits(digest) < difficulty) {
|
|
38
|
+
return { ok: false, reason: 'pow_insufficient' }
|
|
39
|
+
}
|
|
40
|
+
return { ok: true }
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Count leading zero bits in a hex digest. 4 bits per nibble — once we
|
|
46
|
+
* hit a non-zero nibble we add the leading zeros within it and stop.
|
|
47
|
+
*/
|
|
48
|
+
export function countLeadingZeroBits(hex: string): number {
|
|
49
|
+
let bits = 0
|
|
50
|
+
for (let i = 0; i < hex.length; i++) {
|
|
51
|
+
const nibble = parseInt(hex[i]!, 16)
|
|
52
|
+
if (nibble === 0) {
|
|
53
|
+
bits += 4
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
// Count leading zeros in this nibble (1..3, since nibble != 0)
|
|
57
|
+
if (nibble < 0b0010) bits += 3
|
|
58
|
+
else if (nibble < 0b0100) bits += 2
|
|
59
|
+
else if (nibble < 0b1000) bits += 1
|
|
60
|
+
break
|
|
61
|
+
}
|
|
62
|
+
return bits
|
|
63
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { encrypt } from '@strav/kernel'
|
|
2
|
+
import type { ChallengeType } from '../types.ts'
|
|
3
|
+
import { hashAnswer, safeEqual } from '../token.ts'
|
|
4
|
+
|
|
5
|
+
const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' // no 0/O/I/1
|
|
6
|
+
const LENGTH = 6
|
|
7
|
+
const WIDTH = 200
|
|
8
|
+
const HEIGHT = 70
|
|
9
|
+
const FONT_SIZE = 38
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Distorted-text challenge rendered as inline SVG — no canvas, no font
|
|
13
|
+
* files, no external requests. Each glyph gets a small per-character
|
|
14
|
+
* rotation and vertical jitter; a few decoy strokes cross the field to
|
|
15
|
+
* break naive segmentation.
|
|
16
|
+
*
|
|
17
|
+
* Defense level: better than no captcha, far weaker than reCAPTCHA. The
|
|
18
|
+
* point is to make scripted bulk submission expensive, not to stop a
|
|
19
|
+
* targeted attacker who'll route to a human solver. Layer with PoW for
|
|
20
|
+
* compounding cost.
|
|
21
|
+
*/
|
|
22
|
+
export const svgChallenge: ChallengeType = {
|
|
23
|
+
name: 'svg',
|
|
24
|
+
issue() {
|
|
25
|
+
const answer = randomCode(LENGTH)
|
|
26
|
+
return {
|
|
27
|
+
props: {},
|
|
28
|
+
answer,
|
|
29
|
+
html: renderSvg(answer),
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
verify(payload, response) {
|
|
33
|
+
if (typeof response !== 'string' || response.trim() === '') {
|
|
34
|
+
return { ok: false, reason: 'answer_mismatch' }
|
|
35
|
+
}
|
|
36
|
+
if (!payload.ah) return { ok: false, reason: 'token_invalid' }
|
|
37
|
+
const expected = hashAnswer(response, payload.s)
|
|
38
|
+
if (!safeEqual(expected, payload.ah)) {
|
|
39
|
+
return { ok: false, reason: 'answer_mismatch' }
|
|
40
|
+
}
|
|
41
|
+
return { ok: true }
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Pick `n` characters from the confusables-pruned alphabet. */
|
|
46
|
+
function randomCode(n: number): string {
|
|
47
|
+
const bytes = encrypt.randomBytes(n)
|
|
48
|
+
let out = ''
|
|
49
|
+
for (let i = 0; i < n; i++) {
|
|
50
|
+
out += ALPHABET[bytes[i]! % ALPHABET.length]
|
|
51
|
+
}
|
|
52
|
+
return out
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build the SVG body for a code. We seed RNG from the code so re-rendering
|
|
57
|
+
* the same answer (e.g. for caching) yields the same image — but since
|
|
58
|
+
* the salt + jti rotate per challenge issuance, the image still differs
|
|
59
|
+
* across requests.
|
|
60
|
+
*/
|
|
61
|
+
export function renderSvg(code: string): string {
|
|
62
|
+
// Bun's Math.random is fine for visual jitter — security comes from
|
|
63
|
+
// the answer hash, not from making the SVG unpredictable.
|
|
64
|
+
const glyphs: string[] = []
|
|
65
|
+
const stepX = (WIDTH - 30) / code.length
|
|
66
|
+
const baselineY = HEIGHT / 2 + FONT_SIZE / 3
|
|
67
|
+
|
|
68
|
+
for (let i = 0; i < code.length; i++) {
|
|
69
|
+
const ch = escapeXml(code[i]!)
|
|
70
|
+
const x = 20 + stepX * i
|
|
71
|
+
const y = baselineY + (Math.random() * 10 - 5)
|
|
72
|
+
const rot = Math.random() * 40 - 20
|
|
73
|
+
const skew = Math.random() * 16 - 8
|
|
74
|
+
const fill = `hsl(${Math.floor(Math.random() * 360)}, 60%, 35%)`
|
|
75
|
+
glyphs.push(
|
|
76
|
+
`<text x="${x.toFixed(1)}" y="${y.toFixed(1)}" font-size="${FONT_SIZE}" ` +
|
|
77
|
+
`font-family="Helvetica, Arial, sans-serif" font-weight="700" fill="${fill}" ` +
|
|
78
|
+
`transform="rotate(${rot.toFixed(1)} ${x.toFixed(1)} ${y.toFixed(1)}) skewX(${skew.toFixed(1)})">` +
|
|
79
|
+
`${ch}</text>`
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Two distractor strokes — cheap segmentation breaker.
|
|
84
|
+
const lines: string[] = []
|
|
85
|
+
for (let i = 0; i < 2; i++) {
|
|
86
|
+
const x1 = Math.random() * WIDTH
|
|
87
|
+
const x2 = Math.random() * WIDTH
|
|
88
|
+
const y1 = Math.random() * HEIGHT
|
|
89
|
+
const y2 = Math.random() * HEIGHT
|
|
90
|
+
const stroke = `hsl(${Math.floor(Math.random() * 360)}, 50%, 40%)`
|
|
91
|
+
lines.push(
|
|
92
|
+
`<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="${stroke}" stroke-width="1.5" />`
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${HEIGHT}" viewBox="0 0 ${WIDTH} ${HEIGHT}" ` +
|
|
98
|
+
`role="img" aria-label="Type the characters shown">` +
|
|
99
|
+
`<rect width="100%" height="100%" fill="#f5f5f5"/>` +
|
|
100
|
+
lines.join('') +
|
|
101
|
+
glyphs.join('') +
|
|
102
|
+
`</svg>`
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function escapeXml(s: string): string {
|
|
107
|
+
return s
|
|
108
|
+
.replace(/&/g, '&')
|
|
109
|
+
.replace(/</g, '<')
|
|
110
|
+
.replace(/>/g, '>')
|
|
111
|
+
.replace(/"/g, '"')
|
|
112
|
+
.replace(/'/g, ''')
|
|
113
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
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 }
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Middleware
|
|
2
|
+
export { captcha } from './middleware.ts'
|
|
3
|
+
|
|
4
|
+
// Issue / verify primitives
|
|
5
|
+
export { issueChallenge, verifyChallenge } from './challenges.ts'
|
|
6
|
+
export { honeypotChallenge, powChallenge, svgChallenge } from './challenges.ts'
|
|
7
|
+
|
|
8
|
+
// Registry — for custom challenge types
|
|
9
|
+
export { registerChallengeType, getChallengeType, listChallengeTypes } from './registry.ts'
|
|
10
|
+
|
|
11
|
+
// Validation rule
|
|
12
|
+
export { captchaRule } from './validation_rule.ts'
|
|
13
|
+
|
|
14
|
+
// Refresh route
|
|
15
|
+
export { mountCaptchaRoutes } from './route.ts'
|
|
16
|
+
|
|
17
|
+
// View helper / @captcha directive support
|
|
18
|
+
export {
|
|
19
|
+
installCaptchaHelpers,
|
|
20
|
+
configureCaptchaHelper,
|
|
21
|
+
captchaHelper,
|
|
22
|
+
} from './view_helper.ts'
|
|
23
|
+
|
|
24
|
+
// Token utilities (exposed so consumers can build custom flows)
|
|
25
|
+
export { sealToken, unsealToken, hashAnswer, safeEqual, consumeReplay } from './token.ts'
|
|
26
|
+
|
|
27
|
+
// SVG renderer (exposed for users who want to render outside the directive)
|
|
28
|
+
export { renderSvg } from './challenges/svg.ts'
|
|
29
|
+
|
|
30
|
+
// PoW utilities (count leading zero bits — useful for testing)
|
|
31
|
+
export { countLeadingZeroBits } from './challenges/pow.ts'
|
|
32
|
+
|
|
33
|
+
// Types
|
|
34
|
+
export { DEFAULTS } from './types.ts'
|
|
35
|
+
export type {
|
|
36
|
+
ChallengeName,
|
|
37
|
+
ChallengeType,
|
|
38
|
+
CaptchaTokenPayload,
|
|
39
|
+
CaptchaOptions,
|
|
40
|
+
IssueOptions,
|
|
41
|
+
IssuedChallenge,
|
|
42
|
+
VerifyResult,
|
|
43
|
+
FailureReason,
|
|
44
|
+
} from './types.ts'
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { onMounted, ref } from 'vue'
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
challenge: string
|
|
6
|
+
difficulty: number
|
|
7
|
+
tokenField?: string
|
|
8
|
+
responseField?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
12
|
+
tokenField: '_captcha',
|
|
13
|
+
responseField: '_captcha_answer',
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const status = ref<'idle' | 'solving' | 'solved' | 'error'>('idle')
|
|
17
|
+
const elapsedMs = ref(0)
|
|
18
|
+
|
|
19
|
+
onMounted(() => {
|
|
20
|
+
void solve()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
async function solve() {
|
|
24
|
+
status.value = 'solving'
|
|
25
|
+
const start = performance.now()
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const nonce = await findNonce(props.challenge, props.difficulty)
|
|
29
|
+
elapsedMs.value = Math.round(performance.now() - start)
|
|
30
|
+
writeAnswer(nonce)
|
|
31
|
+
status.value = 'solved'
|
|
32
|
+
} catch (err) {
|
|
33
|
+
console.error('[captcha/pow] failed', err)
|
|
34
|
+
status.value = 'error'
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Find a nonce N such that sha256(challenge + ':' + N) has at least
|
|
40
|
+
* `difficulty` leading zero bits. We try a counter — simple and easy to
|
|
41
|
+
* verify on the server with the same construction.
|
|
42
|
+
*
|
|
43
|
+
* Yields back to the event loop every 256 attempts so the page stays
|
|
44
|
+
* responsive on phones / weaker CPUs.
|
|
45
|
+
*/
|
|
46
|
+
async function findNonce(challenge: string, difficulty: number): Promise<string> {
|
|
47
|
+
if (typeof crypto === 'undefined' || !crypto.subtle) {
|
|
48
|
+
throw new Error('Web Crypto API unavailable')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const encoder = new TextEncoder()
|
|
52
|
+
let nonce = 0
|
|
53
|
+
|
|
54
|
+
while (true) {
|
|
55
|
+
for (let i = 0; i < 256; i++) {
|
|
56
|
+
const candidate = String(nonce++)
|
|
57
|
+
const buffer = await crypto.subtle.digest(
|
|
58
|
+
'SHA-256',
|
|
59
|
+
encoder.encode(challenge + ':' + candidate)
|
|
60
|
+
)
|
|
61
|
+
if (leadingZeroBits(new Uint8Array(buffer)) >= difficulty) {
|
|
62
|
+
return candidate
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Yield to keep the UI alive
|
|
66
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function leadingZeroBits(bytes: Uint8Array): number {
|
|
71
|
+
let bits = 0
|
|
72
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
73
|
+
const b = bytes[i]!
|
|
74
|
+
if (b === 0) {
|
|
75
|
+
bits += 8
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
let n = b
|
|
79
|
+
while ((n & 0x80) === 0) {
|
|
80
|
+
bits++
|
|
81
|
+
n <<= 1
|
|
82
|
+
}
|
|
83
|
+
break
|
|
84
|
+
}
|
|
85
|
+
return bits
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Write the nonce into the form's hidden answer field. We look up the
|
|
90
|
+
* nearest enclosing form and find the input by name — robust against
|
|
91
|
+
* forms that re-render or move the widget around.
|
|
92
|
+
*/
|
|
93
|
+
function writeAnswer(nonce: string) {
|
|
94
|
+
const root = document.querySelector(`[data-vue="captcha/pow"]`)
|
|
95
|
+
const form = root?.closest('form')
|
|
96
|
+
if (!form) return
|
|
97
|
+
const input = form.querySelector(`input[name="${props.responseField}"]`)
|
|
98
|
+
if (input instanceof HTMLInputElement) input.value = nonce
|
|
99
|
+
}
|
|
100
|
+
</script>
|
|
101
|
+
|
|
102
|
+
<template>
|
|
103
|
+
<div class="captcha-pow" :data-status="status">
|
|
104
|
+
<span v-if="status === 'idle'" aria-hidden="true">⋯</span>
|
|
105
|
+
<span v-else-if="status === 'solving'">Verifying you're human…</span>
|
|
106
|
+
<span v-else-if="status === 'solved'" aria-live="polite">✓ Verified ({{ elapsedMs }}ms)</span>
|
|
107
|
+
<span v-else class="captcha-pow-error">Verification failed</span>
|
|
108
|
+
</div>
|
|
109
|
+
</template>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
interface Props {
|
|
3
|
+
tokenField?: string
|
|
4
|
+
refreshUrl?: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
8
|
+
tokenField: '_captcha',
|
|
9
|
+
refreshUrl: '/__captcha/svg',
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
async function refresh() {
|
|
13
|
+
const response = await fetch(props.refreshUrl, { headers: { Accept: 'image/svg+xml' } })
|
|
14
|
+
if (!response.ok) return
|
|
15
|
+
const newToken = response.headers.get('X-Captcha-Token')
|
|
16
|
+
const svgBody = await response.text()
|
|
17
|
+
|
|
18
|
+
// Find the SVG sibling — refresh button is a span inside the
|
|
19
|
+
// `.captcha-svg` wrapper that also holds the SSR-rendered SVG.
|
|
20
|
+
const root = document.querySelector('[data-vue="captcha/refresh"]')
|
|
21
|
+
const wrapper = root?.parentElement
|
|
22
|
+
const oldSvg = wrapper?.querySelector('svg')
|
|
23
|
+
if (oldSvg) {
|
|
24
|
+
const tmp = document.createElement('div')
|
|
25
|
+
tmp.innerHTML = svgBody
|
|
26
|
+
const newSvg = tmp.querySelector('svg')
|
|
27
|
+
if (newSvg) oldSvg.replaceWith(newSvg)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Update the hidden token in the same <form>.
|
|
31
|
+
if (newToken) {
|
|
32
|
+
const form = wrapper?.closest('form')
|
|
33
|
+
const input = form?.querySelector(`input[name="${props.tokenField}"]`)
|
|
34
|
+
if (input instanceof HTMLInputElement) input.value = newToken
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<template>
|
|
40
|
+
<button type="button" class="captcha-refresh" @click="refresh" aria-label="Refresh challenge">
|
|
41
|
+
↻
|
|
42
|
+
</button>
|
|
43
|
+
</template>
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { Middleware, Context, Session } from '@strav/http'
|
|
2
|
+
import { CacheManager } from '@strav/kernel'
|
|
3
|
+
import type { CacheStore } from '@strav/kernel'
|
|
4
|
+
import type { CaptchaOptions, FailureReason, ChallengeName } from './types.ts'
|
|
5
|
+
import { DEFAULTS } from './types.ts'
|
|
6
|
+
import { verifyChallenge } from './challenges.ts'
|
|
7
|
+
import { consumeReplay } from './token.ts'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Form/JSON guard. Place after `csrf()` and (optionally) after `rateLimit()`
|
|
11
|
+
* so cheap rejections don't even attempt CAPTCHA verification.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* router.post('/register', [
|
|
15
|
+
* rateLimit({ window: 60_000, max: 5 }),
|
|
16
|
+
* csrf(),
|
|
17
|
+
* captcha({ types: ['honeypot', 'pow'] }),
|
|
18
|
+
* ], handler)
|
|
19
|
+
*/
|
|
20
|
+
export function captcha(options: CaptchaOptions = {}): Middleware {
|
|
21
|
+
const types = (options.types ?? ['honeypot', 'pow']) as ChallengeName[]
|
|
22
|
+
const honeypotField = options.honeypotField ?? DEFAULTS.honeypotField
|
|
23
|
+
const tokenField = options.tokenField ?? DEFAULTS.tokenField
|
|
24
|
+
const responseField = options.responseField ?? DEFAULTS.responseField
|
|
25
|
+
|
|
26
|
+
// Lazy default — calling CacheManager.store at module-load would throw
|
|
27
|
+
// when the captcha import lands before the container is wired.
|
|
28
|
+
const resolveStore = (): CacheStore => options.store ?? CacheManager.store
|
|
29
|
+
|
|
30
|
+
const useHoneypot = types.includes('honeypot')
|
|
31
|
+
|
|
32
|
+
return async (ctx, next) => {
|
|
33
|
+
if (options.skip?.(ctx)) return next()
|
|
34
|
+
|
|
35
|
+
// Only guard state-changing methods. GET/HEAD shouldn't carry forms.
|
|
36
|
+
if (['GET', 'HEAD', 'OPTIONS'].includes(ctx.method)) return next()
|
|
37
|
+
|
|
38
|
+
const body = await readBody(ctx)
|
|
39
|
+
|
|
40
|
+
if (useHoneypot) {
|
|
41
|
+
const trap = body[honeypotField]
|
|
42
|
+
if (typeof trap === 'string' && trap.trim() !== '') {
|
|
43
|
+
return failure(ctx, 'honeypot_tripped', body, options)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// If honeypot is the only requested type, we're done.
|
|
48
|
+
const challengeTypes = types.filter(t => t !== 'honeypot')
|
|
49
|
+
if (challengeTypes.length === 0) return next()
|
|
50
|
+
|
|
51
|
+
const token = typeof body[tokenField] === 'string' ? (body[tokenField] as string) : ''
|
|
52
|
+
if (!token) return failure(ctx, 'token_missing', body, options)
|
|
53
|
+
|
|
54
|
+
const result = verifyChallenge(token, body[responseField], body)
|
|
55
|
+
if (!result.ok) return failure(ctx, result.reason, body, options)
|
|
56
|
+
|
|
57
|
+
if (!challengeTypes.includes(result.payload.t)) {
|
|
58
|
+
// Token is valid but for a type this guard wasn't asked to accept —
|
|
59
|
+
// could happen if a challenge was issued for a different form.
|
|
60
|
+
return failure(ctx, 'token_invalid', body, options)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const fresh = await consumeReplay(resolveStore(), result.payload)
|
|
64
|
+
if (!fresh) return failure(ctx, 'replayed', body, options)
|
|
65
|
+
|
|
66
|
+
return next()
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Read the parsed body without crashing on text/plain or empty bodies.
|
|
72
|
+
* Returns `{}` so callers can index without null-checks.
|
|
73
|
+
*/
|
|
74
|
+
async function readBody(ctx: Context): Promise<Record<string, unknown>> {
|
|
75
|
+
try {
|
|
76
|
+
const body = await ctx.body<unknown>()
|
|
77
|
+
return body && typeof body === 'object' ? (body as Record<string, unknown>) : {}
|
|
78
|
+
} catch {
|
|
79
|
+
return {}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function failure(
|
|
84
|
+
ctx: Context,
|
|
85
|
+
reason: FailureReason,
|
|
86
|
+
body: Record<string, unknown>,
|
|
87
|
+
options: CaptchaOptions
|
|
88
|
+
): Promise<Response> {
|
|
89
|
+
if (options.onFailure) return options.onFailure(ctx, reason)
|
|
90
|
+
|
|
91
|
+
if (wantsJson(ctx)) {
|
|
92
|
+
return ctx.json({ errors: { _captcha: [reason] } }, 422)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Form path: flash errors + old input, redirect back to the referer
|
|
96
|
+
// (or root). Mirrors the convention used by `validate()` consumers.
|
|
97
|
+
const session = ctx.get<Session | undefined>('session')
|
|
98
|
+
if (session) {
|
|
99
|
+
session.flash('errors', { _captcha: [reason] })
|
|
100
|
+
session.flash('old', stripCaptchaFields(body, options))
|
|
101
|
+
await session.save()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const referer = ctx.header('referer') ?? '/'
|
|
105
|
+
return ctx.redirect(referer, 303)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function wantsJson(ctx: Context): boolean {
|
|
109
|
+
const accept = ctx.header('accept') ?? ''
|
|
110
|
+
if (accept.includes('application/json')) return true
|
|
111
|
+
if (ctx.header('x-requested-with') === 'XMLHttpRequest') return true
|
|
112
|
+
const contentType = ctx.header('content-type') ?? ''
|
|
113
|
+
return contentType.includes('application/json')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function stripCaptchaFields(
|
|
117
|
+
body: Record<string, unknown>,
|
|
118
|
+
options: CaptchaOptions
|
|
119
|
+
): Record<string, unknown> {
|
|
120
|
+
const out = { ...body }
|
|
121
|
+
delete out[options.honeypotField ?? DEFAULTS.honeypotField]
|
|
122
|
+
delete out[options.tokenField ?? DEFAULTS.tokenField]
|
|
123
|
+
delete out[options.responseField ?? DEFAULTS.responseField]
|
|
124
|
+
return out
|
|
125
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ChallengeName, ChallengeType } from './types.ts'
|
|
2
|
+
|
|
3
|
+
const registry = new Map<string, ChallengeType>()
|
|
4
|
+
|
|
5
|
+
/**
|
|
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
|
+
* })
|
|
16
|
+
*/
|
|
17
|
+
export function registerChallengeType(type: ChallengeType): void {
|
|
18
|
+
registry.set(type.name, type)
|
|
19
|
+
}
|
|
20
|
+
|
|
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
|
+
}
|
|
25
|
+
|
|
26
|
+
/** All registered type names — used by validators and refresh route allow-listing. */
|
|
27
|
+
export function listChallengeTypes(): ChallengeName[] {
|
|
28
|
+
return Array.from(registry.keys())
|
|
29
|
+
}
|
package/src/route.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Router, Context } from '@strav/http'
|
|
2
|
+
import { rateLimit } from '@strav/http'
|
|
3
|
+
import { issueChallenge } from './challenges.ts'
|
|
4
|
+
import { listChallengeTypes } from './registry.ts'
|
|
5
|
+
import type { ChallengeName } from './types.ts'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Mount a refresh endpoint at `<prefix>/:type` so client-side widgets
|
|
9
|
+
* (e.g. the SVG refresh button) can request a new challenge without a
|
|
10
|
+
* full page reload. Stateless, no session required.
|
|
11
|
+
*
|
|
12
|
+
* Auto-rate-limited per IP — 30 issuances/minute by default. Override
|
|
13
|
+
* by passing your own `rateLimit()` middleware in `extraMiddleware`,
|
|
14
|
+
* or set `rateLimit: false` to disable (not recommended in production).
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* mountCaptchaRoutes(router)
|
|
18
|
+
* // → GET /__captcha/svg, GET /__captcha/pow
|
|
19
|
+
*/
|
|
20
|
+
export function mountCaptchaRoutes(
|
|
21
|
+
router: Router,
|
|
22
|
+
options: {
|
|
23
|
+
prefix?: string
|
|
24
|
+
rateLimit?: false | { window?: number; max?: number }
|
|
25
|
+
extraMiddleware?: ReturnType<typeof rateLimit>[]
|
|
26
|
+
} = {}
|
|
27
|
+
): void {
|
|
28
|
+
const prefix = options.prefix ?? '/__captcha'
|
|
29
|
+
|
|
30
|
+
const middleware = []
|
|
31
|
+
if (options.extraMiddleware?.length) {
|
|
32
|
+
middleware.push(...options.extraMiddleware)
|
|
33
|
+
} else if (options.rateLimit !== false) {
|
|
34
|
+
middleware.push(
|
|
35
|
+
rateLimit({
|
|
36
|
+
window: options.rateLimit?.window ?? 60_000,
|
|
37
|
+
max: options.rateLimit?.max ?? 30,
|
|
38
|
+
})
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
router.group({ prefix, middleware }, r => {
|
|
43
|
+
r.get('/:type', async (ctx: Context) => {
|
|
44
|
+
const type = ctx.params.type as ChallengeName
|
|
45
|
+
if (!listChallengeTypes().includes(type)) {
|
|
46
|
+
return ctx.json({ error: 'Unknown challenge type' }, 404)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const issued = issueChallenge(type)
|
|
50
|
+
|
|
51
|
+
// SVG type returns the image directly — easy <img src=…> embed.
|
|
52
|
+
// Other types return JSON so the client can hydrate its own widget.
|
|
53
|
+
if (type === 'svg' && issued.html) {
|
|
54
|
+
return new Response(issued.html, {
|
|
55
|
+
status: 200,
|
|
56
|
+
headers: {
|
|
57
|
+
'Content-Type': 'image/svg+xml',
|
|
58
|
+
'Cache-Control': 'no-store',
|
|
59
|
+
// Pass the token via header so the client can stash it without
|
|
60
|
+
// re-parsing the SVG body. Browsers expose this on fetch responses.
|
|
61
|
+
'X-Captcha-Token': issued.token,
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return ctx.json({
|
|
67
|
+
token: issued.token,
|
|
68
|
+
props: issued.props,
|
|
69
|
+
html: issued.html,
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
}
|
package/src/token.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { encrypt } from '@strav/kernel'
|
|
2
|
+
import type { CacheStore } from '@strav/kernel'
|
|
3
|
+
import type { CaptchaTokenPayload, FailureReason } from './types.ts'
|
|
4
|
+
|
|
5
|
+
const REPLAY_PREFIX = 'captcha:used:'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Hash an answer with its per-challenge salt. The salt prevents a global
|
|
9
|
+
* rainbow-table on common SVG answers (`abcd12`, `1234`, …) and ensures
|
|
10
|
+
* two challenges with the same plaintext answer produce different hashes.
|
|
11
|
+
*
|
|
12
|
+
* Lowercase + trim is the canonical normalization — matches what the
|
|
13
|
+
* SVG/math verifiers do at compare time.
|
|
14
|
+
*/
|
|
15
|
+
export function hashAnswer(answer: string, salt: string): string {
|
|
16
|
+
return encrypt.sha256(answer.toLowerCase().trim() + salt)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Constant-time string equality on hex digests. */
|
|
20
|
+
export function safeEqual(a: string, b: string): boolean {
|
|
21
|
+
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
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
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.
|
|
33
|
+
*/
|
|
34
|
+
export function sealToken(data: Omit<CaptchaTokenPayload, 'v' | 'iat' | 'jti'>): {
|
|
35
|
+
token: string
|
|
36
|
+
payload: CaptchaTokenPayload
|
|
37
|
+
} {
|
|
38
|
+
const payload: CaptchaTokenPayload = {
|
|
39
|
+
v: 1,
|
|
40
|
+
iat: Date.now(),
|
|
41
|
+
jti: encrypt.random(16),
|
|
42
|
+
...data,
|
|
43
|
+
}
|
|
44
|
+
return { token: encrypt.seal(payload), payload }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Decode a sealed token. Returns the payload, or a failure reason that
|
|
49
|
+
* the verifier can short-circuit on (invalid seal, expired).
|
|
50
|
+
*
|
|
51
|
+
* 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.
|
|
54
|
+
*/
|
|
55
|
+
export function unsealToken(
|
|
56
|
+
token: string
|
|
57
|
+
): { ok: true; payload: CaptchaTokenPayload } | { ok: false; reason: FailureReason } {
|
|
58
|
+
let payload: CaptchaTokenPayload
|
|
59
|
+
try {
|
|
60
|
+
payload = encrypt.unseal<CaptchaTokenPayload>(token)
|
|
61
|
+
} catch {
|
|
62
|
+
return { ok: false, reason: 'token_invalid' }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!payload || payload.v !== 1 || typeof payload.iat !== 'number') {
|
|
66
|
+
return { ok: false, reason: 'token_invalid' }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (Date.now() > payload.iat + payload.exp * 60_000) {
|
|
70
|
+
return { ok: false, reason: 'token_expired' }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { ok: true, payload }
|
|
74
|
+
}
|
|
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
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { CacheStore } from '@strav/kernel'
|
|
2
|
+
import type { Middleware } from '@strav/http'
|
|
3
|
+
|
|
4
|
+
/** Built-in challenge type names. Extensible via `registerChallengeType()`. */
|
|
5
|
+
export type ChallengeName = 'honeypot' | 'pow' | 'svg' | (string & {})
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Reasons a CAPTCHA verification can fail. Surfaced to `onFailure` and
|
|
9
|
+
* (when JSON) returned to the client so the form can display targeted help.
|
|
10
|
+
*/
|
|
11
|
+
export type FailureReason =
|
|
12
|
+
| 'token_missing'
|
|
13
|
+
| 'token_invalid'
|
|
14
|
+
| 'token_expired'
|
|
15
|
+
| 'answer_mismatch'
|
|
16
|
+
| 'replayed'
|
|
17
|
+
| 'honeypot_tripped'
|
|
18
|
+
| 'pow_insufficient'
|
|
19
|
+
| 'unknown_type'
|
|
20
|
+
|
|
21
|
+
/**
|
|
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.
|
|
26
|
+
*/
|
|
27
|
+
export interface CaptchaTokenPayload {
|
|
28
|
+
/** Schema version. Bump on breaking changes. */
|
|
29
|
+
v: 1
|
|
30
|
+
/** Challenge type — chooses the verifier. */
|
|
31
|
+
t: ChallengeName
|
|
32
|
+
/** sha256(normalize(answer) + salt) — omitted for honeypot. */
|
|
33
|
+
ah?: string
|
|
34
|
+
/** Random per-challenge salt (hex). Hashed with the answer. */
|
|
35
|
+
s: string
|
|
36
|
+
/** PoW difficulty in leading zero bits. Only set when `t === 'pow'`. */
|
|
37
|
+
d?: number
|
|
38
|
+
/** Issued-at, ms since epoch. */
|
|
39
|
+
iat: number
|
|
40
|
+
/** Expiry duration in minutes. */
|
|
41
|
+
exp: number
|
|
42
|
+
/** Replay cache key — a hex random per challenge. */
|
|
43
|
+
jti: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** What `issueChallenge()` hands back — token + render data for the form. */
|
|
47
|
+
export interface IssuedChallenge {
|
|
48
|
+
/** Sealed token to embed in a hidden form field. */
|
|
49
|
+
token: string
|
|
50
|
+
/** Type-specific data the renderer needs (e.g. `{ challenge, difficulty }`). */
|
|
51
|
+
props: Record<string, unknown>
|
|
52
|
+
/** Optional pre-rendered HTML/SVG (used by `svg`). */
|
|
53
|
+
html?: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Options passed to `issueChallenge()`. */
|
|
57
|
+
export interface IssueOptions {
|
|
58
|
+
/** Override default expiry. */
|
|
59
|
+
ttlMinutes?: number
|
|
60
|
+
/** PoW only — leading zero bits. */
|
|
61
|
+
difficulty?: number
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Result of `verifyChallenge()`. `ok: true` consumes the replay slot. */
|
|
65
|
+
export type VerifyResult =
|
|
66
|
+
| { ok: true; payload: CaptchaTokenPayload }
|
|
67
|
+
| { ok: false; reason: FailureReason }
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A challenge type plugged into the registry. `verify` receives the
|
|
71
|
+
* decoded payload plus the user's response and the current cache, and
|
|
72
|
+
* returns either ok or a `FailureReason`.
|
|
73
|
+
*/
|
|
74
|
+
export interface ChallengeType {
|
|
75
|
+
name: ChallengeName
|
|
76
|
+
/**
|
|
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).
|
|
83
|
+
*/
|
|
84
|
+
issue(opts: IssueOptions & { salt: string }): {
|
|
85
|
+
props: Record<string, unknown>
|
|
86
|
+
/** Extra fields merged into the token payload (e.g. `{ d: difficulty }`). */
|
|
87
|
+
extra?: Partial<Pick<CaptchaTokenPayload, 'd'>>
|
|
88
|
+
/** Plaintext answer the user is expected to produce — hashed with salt before sealing. */
|
|
89
|
+
answer?: string
|
|
90
|
+
/** Optional pre-rendered HTML (e.g. SVG body). */
|
|
91
|
+
html?: string
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Verify the response against the sealed payload. Pure function — replay
|
|
95
|
+
* prevention happens upstream, not here.
|
|
96
|
+
*/
|
|
97
|
+
verify(
|
|
98
|
+
payload: CaptchaTokenPayload,
|
|
99
|
+
response: unknown,
|
|
100
|
+
body: Record<string, unknown>
|
|
101
|
+
): { ok: true } | { ok: false; reason: FailureReason }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Options for the `captcha()` middleware. */
|
|
105
|
+
export interface CaptchaOptions {
|
|
106
|
+
/** Challenge types accepted by this guard. @default ['honeypot', 'pow'] */
|
|
107
|
+
types?: ChallengeName[]
|
|
108
|
+
/** Hidden field bots fill in. @default 'website' */
|
|
109
|
+
honeypotField?: string
|
|
110
|
+
/** Form field carrying the sealed token. @default '_captcha' */
|
|
111
|
+
tokenField?: string
|
|
112
|
+
/** Form field carrying the user's answer / nonce. @default '_captcha_answer' */
|
|
113
|
+
responseField?: string
|
|
114
|
+
/** PoW difficulty in leading zero bits. @default 18 */
|
|
115
|
+
difficulty?: number
|
|
116
|
+
/** Token lifetime in minutes. @default 10 */
|
|
117
|
+
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
|
+
}
|
|
128
|
+
|
|
129
|
+
export const DEFAULTS = {
|
|
130
|
+
honeypotField: 'website',
|
|
131
|
+
tokenField: '_captcha',
|
|
132
|
+
responseField: '_captcha_answer',
|
|
133
|
+
difficulty: 18,
|
|
134
|
+
ttlMinutes: 10,
|
|
135
|
+
} as const
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Rule } from '@strav/http'
|
|
2
|
+
import { verifyChallenge } from './challenges.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Validation rule for handlers that prefer `validate()` integration over
|
|
6
|
+
* the middleware. Bind the user's response (and optionally the full body
|
|
7
|
+
* for honeypot/extension checks) when constructing the rule — closure
|
|
8
|
+
* keeps it per-request, no shared mutable state.
|
|
9
|
+
*
|
|
10
|
+
* NOTE: this rule does not perform replay prevention — `validate()` is
|
|
11
|
+
* sync and has no place to read a cache store. For replay-safe checks
|
|
12
|
+
* use the `captcha()` middleware. The rule fits idempotent endpoints
|
|
13
|
+
* where double-submit is harmless.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* const body = await ctx.body<Record<string, unknown>>()
|
|
17
|
+
* const { errors } = validate(body, {
|
|
18
|
+
* _captcha: [captchaRule(body._captcha_answer, body)],
|
|
19
|
+
* email: [required(), email()],
|
|
20
|
+
* })
|
|
21
|
+
*/
|
|
22
|
+
export function captchaRule(response: unknown, body?: Record<string, unknown>): Rule {
|
|
23
|
+
return {
|
|
24
|
+
name: 'captcha',
|
|
25
|
+
validate(value) {
|
|
26
|
+
const token = typeof value === 'string' ? value : ''
|
|
27
|
+
const result = verifyChallenge(token, response, body ?? {})
|
|
28
|
+
return result.ok ? null : `captcha.${result.reason}`
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { ViewEngine } from '@strav/view'
|
|
2
|
+
import { issueChallenge } from './challenges.ts'
|
|
3
|
+
import { DEFAULTS } from './types.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Default field names. Kept on the helper itself so a single config
|
|
7
|
+
* point flows into both middleware and template rendering — change
|
|
8
|
+
* these and `@captcha` re-emits matching field names automatically.
|
|
9
|
+
*/
|
|
10
|
+
const config = {
|
|
11
|
+
honeypotField: DEFAULTS.honeypotField,
|
|
12
|
+
tokenField: DEFAULTS.tokenField,
|
|
13
|
+
responseField: DEFAULTS.responseField,
|
|
14
|
+
difficulty: DEFAULTS.difficulty,
|
|
15
|
+
ttlMinutes: DEFAULTS.ttlMinutes,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Optionally configure helper-side defaults so the markup matches the
|
|
20
|
+
* server-side `captcha()` middleware options. Required only when an app
|
|
21
|
+
* customizes field names — defaults match middleware defaults.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* configureCaptchaHelper({ honeypotField: 'phone_number' })
|
|
25
|
+
*/
|
|
26
|
+
export function configureCaptchaHelper(opts: Partial<typeof config>): void {
|
|
27
|
+
Object.assign(config, opts)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Render the form fragment for a challenge. Returns ready-to-emit HTML
|
|
32
|
+
* including the honeypot, the hidden token, and (for visible types) the
|
|
33
|
+
* answer input + island/SVG.
|
|
34
|
+
*
|
|
35
|
+
* Synchronous: `issueChallenge` is sync, the renderer just concatenates
|
|
36
|
+
* strings — safe to call from inside the template render loop.
|
|
37
|
+
*/
|
|
38
|
+
export function captchaHelper(variant?: string): string {
|
|
39
|
+
const honeypot = renderHoneypot()
|
|
40
|
+
|
|
41
|
+
if (!variant || variant === 'honeypot') return honeypot
|
|
42
|
+
|
|
43
|
+
if (variant === 'pow') {
|
|
44
|
+
const issued = issueChallenge('pow', { difficulty: config.difficulty, ttlMinutes: config.ttlMinutes })
|
|
45
|
+
const props = JSON.stringify({
|
|
46
|
+
...issued.props,
|
|
47
|
+
tokenField: config.tokenField,
|
|
48
|
+
responseField: config.responseField,
|
|
49
|
+
}).replace(/'/g, ''')
|
|
50
|
+
return (
|
|
51
|
+
honeypot +
|
|
52
|
+
`<input type="hidden" name="${attr(config.tokenField)}" value="${attr(issued.token)}">` +
|
|
53
|
+
`<input type="hidden" name="${attr(config.responseField)}" value="">` +
|
|
54
|
+
`<div data-vue="captcha/pow" data-props='${props}'></div>`
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (variant === 'svg') {
|
|
59
|
+
const issued = issueChallenge('svg', { ttlMinutes: config.ttlMinutes })
|
|
60
|
+
const refreshProps = JSON.stringify({ tokenField: config.tokenField }).replace(/'/g, ''')
|
|
61
|
+
return (
|
|
62
|
+
honeypot +
|
|
63
|
+
`<input type="hidden" name="${attr(config.tokenField)}" value="${attr(issued.token)}">` +
|
|
64
|
+
`<div class="captcha-svg">` +
|
|
65
|
+
(issued.html ?? '') +
|
|
66
|
+
// Sibling-not-child: the Vue island replaces this element's content,
|
|
67
|
+
// but the SVG above stays untouched until the user clicks refresh.
|
|
68
|
+
`<span data-vue="captcha/refresh" data-props='${refreshProps}'></span>` +
|
|
69
|
+
`</div>` +
|
|
70
|
+
`<input type="text" name="${attr(config.responseField)}" autocomplete="off" ` +
|
|
71
|
+
`inputmode="latin" autocapitalize="characters" required aria-label="Type the characters shown">`
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
throw new Error(`@captcha: unknown variant "${variant}" (expected 'pow' | 'svg' | 'honeypot' or none)`)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function renderHoneypot(): string {
|
|
79
|
+
const name = attr(config.honeypotField)
|
|
80
|
+
// Triple defense: aria-hidden, tabindex=-1, off-screen via inline style.
|
|
81
|
+
// Don't use `display:none` — some bots skip those fields.
|
|
82
|
+
return (
|
|
83
|
+
`<div aria-hidden="true" style="position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden">` +
|
|
84
|
+
`<label for="${name}">Leave this field empty</label>` +
|
|
85
|
+
`<input type="text" id="${name}" name="${name}" tabindex="-1" autocomplete="off" value="">` +
|
|
86
|
+
`</div>`
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function attr(s: string): string {
|
|
91
|
+
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Wire `__captcha(variant)` into the view engine so `@captcha` works.
|
|
96
|
+
* Call once at boot, after ViewEngine is constructed.
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* import { installCaptchaHelpers } from '@strav/captcha'
|
|
100
|
+
* // …after ViewEngine is registered in the container
|
|
101
|
+
* installCaptchaHelpers()
|
|
102
|
+
*/
|
|
103
|
+
export function installCaptchaHelpers(): void {
|
|
104
|
+
ViewEngine.setGlobal('__captcha', captchaHelper)
|
|
105
|
+
}
|