k9guard 1.0.1 → 1.0.3
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/LICENSE +21 -0
- package/README.md +86 -49
- package/docs/tr/README.md +88 -51
- package/package.json +7 -7
- package/src/K9Guard.ts +26 -12
- package/src/core/captchaGenerator.ts +207 -68
- package/src/core/captchaValidator.ts +96 -15
- package/src/types/index.ts +34 -22
- package/src/utils/crypto.ts +234 -258
- package/src/utils/emojiGenerator.ts +88 -0
- package/src/utils/imageGenerator.ts +154 -0
- package/src/utils/random.ts +2 -2
- package/src/utils/reverseGenerator.ts +5 -1
- package/src/utils/scrambleGenerator.ts +25 -13
- package/src/utils/sequenceGenerator.ts +15 -14
- package/src/locale/en.json +0 -46
- package/src/locale/tr.json +0 -46
- package/src/utils/logicGenerator.ts +0 -18
- package/src/utils/riddleBank.ts +0 -18
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
import { createHash, randomBytes } from '../utils/crypto';
|
|
2
2
|
import { Random } from '../utils/random';
|
|
3
|
-
import { RiddleBank } from '../utils/riddleBank';
|
|
4
3
|
import { SequenceGenerator } from '../utils/sequenceGenerator';
|
|
5
4
|
import { ScrambleGenerator } from '../utils/scrambleGenerator';
|
|
6
|
-
import { LogicGenerator } from '../utils/logicGenerator';
|
|
7
5
|
import { ReverseGenerator } from '../utils/reverseGenerator';
|
|
8
6
|
import { CustomQuestionGenerator } from '../utils/customQuestionGenerator';
|
|
9
7
|
import { CustomQuestionValidator } from '../validators/customQuestionValidator';
|
|
10
|
-
import
|
|
8
|
+
import { ImageGenerator } from '../utils/imageGenerator';
|
|
9
|
+
import { EmojiGenerator } from '../utils/emojiGenerator';
|
|
10
|
+
import type { K9GuardOptions, K9GuardCustomOptions, CaptchaChallenge, StoredChallenge, MathCaptcha, TextCaptcha, SequenceCaptcha, ScrambleCaptcha, ReverseCaptcha, MixedCaptcha, CustomCaptcha, ImageCaptcha, EmojiCaptcha, CustomQuestion } from '../types';
|
|
11
|
+
|
|
12
|
+
// Bounded nonce store: evicts the oldest entry once capacity is reached to
|
|
13
|
+
// prevent unbounded memory growth while still blocking same-process replays.
|
|
14
|
+
const NONCE_STORE_MAX = 10_000;
|
|
11
15
|
|
|
12
16
|
export class CaptchaGenerator {
|
|
13
17
|
private standardOptions: K9GuardOptions | null = null;
|
|
14
18
|
private customOptions: K9GuardCustomOptions | null = null;
|
|
15
19
|
private customGenerator: CustomQuestionGenerator | null = null;
|
|
16
|
-
|
|
20
|
+
// Keyed by nonce; stores the full server-side record including answer hash and salt.
|
|
21
|
+
// answer, hashedAnswer and salt are never included in the public CaptchaChallenge.
|
|
22
|
+
private store: Map<string, StoredChallenge> = new Map();
|
|
17
23
|
|
|
18
24
|
// set up the generator and check custom questions if they exist
|
|
19
25
|
constructor(options: K9GuardOptions | K9GuardCustomOptions) {
|
|
@@ -46,39 +52,71 @@ export class CaptchaGenerator {
|
|
|
46
52
|
return this.standardOptions.difficulty;
|
|
47
53
|
}
|
|
48
54
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
55
|
+
// Atomically fetches and removes the stored challenge by nonce.
|
|
56
|
+
// Single-use semantics: the challenge is invalidated on the first attempt (success or failure).
|
|
57
|
+
// Callers must re-generate a new challenge after any validation attempt.
|
|
58
|
+
consume(nonce: string): StoredChallenge | undefined {
|
|
59
|
+
const record = this.store.get(nonce);
|
|
60
|
+
if (record !== undefined) {
|
|
61
|
+
this.store.delete(nonce);
|
|
52
62
|
}
|
|
53
|
-
return
|
|
63
|
+
return record;
|
|
54
64
|
}
|
|
55
65
|
|
|
56
|
-
//
|
|
57
|
-
|
|
66
|
+
// Removes all entries whose expiry has passed to free memory proactively.
|
|
67
|
+
// Called on every generate() to prevent the store from filling with stale records.
|
|
68
|
+
private pruneExpired(): void {
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
for (const [key, record] of this.store) {
|
|
71
|
+
if (now > record.expiry) {
|
|
72
|
+
this.store.delete(key);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Stores the full record server-side; returns a public CaptchaChallenge
|
|
78
|
+
// that is safe to send to the client (no answer, hashedAnswer or salt).
|
|
79
|
+
private createChallenge(base: Omit<StoredChallenge, 'nonce' | 'expiry' | 'hashedAnswer' | 'salt'>): CaptchaChallenge {
|
|
80
|
+
// prune stale entries first so expired records do not displace valid active ones
|
|
81
|
+
this.pruneExpired();
|
|
82
|
+
|
|
58
83
|
let nonce: string;
|
|
59
84
|
do {
|
|
60
|
-
// NOTE: make sure each nonce is unique to stop replay attacks
|
|
61
85
|
nonce = Random.generateNonce();
|
|
62
|
-
} while (this.
|
|
86
|
+
} while (this.store.has(nonce));
|
|
63
87
|
|
|
64
|
-
|
|
88
|
+
// hard cap: if still full after pruning, evict the oldest entry
|
|
89
|
+
if (this.store.size >= NONCE_STORE_MAX) {
|
|
90
|
+
const oldest = this.store.keys().next().value;
|
|
91
|
+
if (oldest !== undefined) {
|
|
92
|
+
this.store.delete(oldest);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
65
95
|
|
|
66
|
-
// challenge will expire in 5 minutes
|
|
67
96
|
const expiry = Date.now() + 5 * 60 * 1000;
|
|
68
97
|
const salt = Random.generateSalt();
|
|
69
|
-
//
|
|
70
|
-
const
|
|
98
|
+
// canonical string for numeric answers: integers as-is, floats at fixed 2 decimal places
|
|
99
|
+
const answerStr = typeof base.answer === 'number'
|
|
100
|
+
? (Number.isInteger(base.answer) ? base.answer.toString() : base.answer.toFixed(2))
|
|
101
|
+
: base.answer.toString();
|
|
102
|
+
const hashedAnswer = createHash('sha256').update(answerStr + salt).digest('hex');
|
|
103
|
+
|
|
104
|
+
const stored: StoredChallenge = { ...base, nonce, expiry, hashedAnswer, salt };
|
|
105
|
+
this.store.set(nonce, stored);
|
|
106
|
+
|
|
107
|
+
// strip sensitive fields before returning to caller; also sanitize nested steps
|
|
108
|
+
const { answer: _answer, hashedAnswer: _ha, salt: _salt, steps: rawSteps, ...rest } = stored;
|
|
109
|
+
const publicChallenge: CaptchaChallenge = { ...rest };
|
|
110
|
+
|
|
111
|
+
if (rawSteps && rawSteps.length > 0) {
|
|
112
|
+
// steps are StoredChallenge objects; only public fields must reach the client
|
|
113
|
+
publicChallenge.steps = rawSteps.map(({ answer: _a, hashedAnswer: _h, salt: _s, steps: _nested, ...pub }) => pub as CaptchaChallenge);
|
|
114
|
+
}
|
|
71
115
|
|
|
72
|
-
return
|
|
73
|
-
...base,
|
|
74
|
-
nonce,
|
|
75
|
-
expiry,
|
|
76
|
-
hashedAnswer,
|
|
77
|
-
salt
|
|
78
|
-
};
|
|
116
|
+
return publicChallenge;
|
|
79
117
|
}
|
|
80
118
|
|
|
81
|
-
//
|
|
119
|
+
// evaluate arithmetic expression for the math captcha type
|
|
82
120
|
private calculateMath(a: number, op: string, b: number): number {
|
|
83
121
|
if (op === '+') {
|
|
84
122
|
return a + b;
|
|
@@ -91,16 +129,16 @@ export class CaptchaGenerator {
|
|
|
91
129
|
}
|
|
92
130
|
if (op === '/') {
|
|
93
131
|
if (b === 0) {
|
|
94
|
-
// can't divide by zero, return NaN
|
|
95
132
|
return Number.NaN;
|
|
96
133
|
}
|
|
97
|
-
|
|
98
|
-
|
|
134
|
+
const raw = a / b;
|
|
135
|
+
// use parseFloat(toFixed(2)) so the stored value is the same canonical string the validator will see
|
|
136
|
+
return parseFloat(raw.toFixed(2));
|
|
99
137
|
}
|
|
100
138
|
return 0;
|
|
101
139
|
}
|
|
102
140
|
|
|
103
|
-
//
|
|
141
|
+
// dispatch to the correct generator based on configured captcha type
|
|
104
142
|
generate(): CaptchaChallenge {
|
|
105
143
|
if (this.customOptions) {
|
|
106
144
|
return this.generateCustom();
|
|
@@ -118,24 +156,24 @@ export class CaptchaGenerator {
|
|
|
118
156
|
if (captchaType === 'text') {
|
|
119
157
|
return this.generateText();
|
|
120
158
|
}
|
|
121
|
-
if (captchaType === 'riddle') {
|
|
122
|
-
return this.generateRiddle();
|
|
123
|
-
}
|
|
124
159
|
if (captchaType === 'sequence') {
|
|
125
160
|
return this.generateSequence();
|
|
126
161
|
}
|
|
127
162
|
if (captchaType === 'scramble') {
|
|
128
163
|
return this.generateScramble();
|
|
129
164
|
}
|
|
130
|
-
if (captchaType === 'logic') {
|
|
131
|
-
return this.generateLogic();
|
|
132
|
-
}
|
|
133
165
|
if (captchaType === 'reverse') {
|
|
134
166
|
return this.generateReverse();
|
|
135
167
|
}
|
|
136
168
|
if (captchaType === 'multi') {
|
|
137
169
|
return this.generateMulti();
|
|
138
170
|
}
|
|
171
|
+
if (captchaType === 'image') {
|
|
172
|
+
return this.generateImage();
|
|
173
|
+
}
|
|
174
|
+
if (captchaType === 'emoji') {
|
|
175
|
+
return this.generateEmoji();
|
|
176
|
+
}
|
|
139
177
|
return this.generateMixed();
|
|
140
178
|
}
|
|
141
179
|
|
|
@@ -145,26 +183,42 @@ export class CaptchaGenerator {
|
|
|
145
183
|
}
|
|
146
184
|
|
|
147
185
|
const custom = this.customGenerator.generate();
|
|
186
|
+
|
|
187
|
+
// an empty answer would allow bypass with any blank input; reject early
|
|
188
|
+
if (!custom.question || !custom.answer) {
|
|
189
|
+
throw new Error('Custom question pool returned an empty question or answer');
|
|
190
|
+
}
|
|
191
|
+
|
|
148
192
|
return this.createChallenge({ type: 'custom', question: custom.question, answer: custom.answer }) as CustomCaptcha;
|
|
149
193
|
}
|
|
150
194
|
|
|
151
195
|
private generateMath(): MathCaptcha {
|
|
152
|
-
|
|
153
|
-
let
|
|
196
|
+
const difficulty = this.getDifficulty();
|
|
197
|
+
let num1 = Random.getRandomNumber(difficulty);
|
|
198
|
+
let num2 = Random.getRandomNumber(difficulty);
|
|
154
199
|
const operator = Random.getRandomOperator();
|
|
155
200
|
|
|
201
|
+
// guarantee non-zero divisor; add 1 so the result is always >= 1
|
|
156
202
|
if (operator === '/' && num2 === 0) {
|
|
157
|
-
num2 = Random.getRandomNumber(
|
|
203
|
+
num2 = Random.getRandomNumber(difficulty) + 1;
|
|
158
204
|
}
|
|
159
205
|
|
|
160
|
-
const question = `${num1} ${operator} ${num2}`;
|
|
161
206
|
const answer = this.calculateMath(num1, operator, num2);
|
|
162
207
|
|
|
163
|
-
|
|
164
|
-
|
|
208
|
+
// calculateMath only returns NaN for division by zero, which is already
|
|
209
|
+
// prevented above — but guard here avoids any future regression without
|
|
210
|
+
// unbounded recursion: iterate instead of recurse
|
|
211
|
+
if (isNaN(answer) || !isFinite(answer)) {
|
|
212
|
+
num1 = Random.getRandomNumber(difficulty);
|
|
213
|
+
num2 = Random.getRandomNumber(difficulty) + 1;
|
|
214
|
+
return this.createChallenge({
|
|
215
|
+
type: 'math',
|
|
216
|
+
question: `${num1} + ${num2}`,
|
|
217
|
+
answer: num1 + num2
|
|
218
|
+
}) as MathCaptcha;
|
|
165
219
|
}
|
|
166
220
|
|
|
167
|
-
return this.createChallenge({ type: 'math', question
|
|
221
|
+
return this.createChallenge({ type: 'math', question: `${num1} ${operator} ${num2}`, answer }) as MathCaptcha;
|
|
168
222
|
}
|
|
169
223
|
|
|
170
224
|
private generateText(): TextCaptcha {
|
|
@@ -172,11 +226,6 @@ export class CaptchaGenerator {
|
|
|
172
226
|
return this.createChallenge({ type: 'text', question: text, answer: text }) as TextCaptcha;
|
|
173
227
|
}
|
|
174
228
|
|
|
175
|
-
private generateRiddle(): RiddleCaptcha {
|
|
176
|
-
const riddle = RiddleBank.getRandom(this.getLocale(), this.getDifficulty());
|
|
177
|
-
return this.createChallenge({ type: 'riddle', question: riddle.question, answer: riddle.answer }) as RiddleCaptcha;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
229
|
private generateSequence(): SequenceCaptcha {
|
|
181
230
|
const seq = SequenceGenerator.generate(this.getDifficulty());
|
|
182
231
|
return this.createChallenge({ type: 'sequence', question: seq.question, answer: seq.answer }) as SequenceCaptcha;
|
|
@@ -187,47 +236,137 @@ export class CaptchaGenerator {
|
|
|
187
236
|
return this.createChallenge({ type: 'scramble', question: scr.question, answer: scr.answer }) as ScrambleCaptcha;
|
|
188
237
|
}
|
|
189
238
|
|
|
190
|
-
private generateLogic(): LogicCaptcha {
|
|
191
|
-
const logic = LogicGenerator.getRandom(this.getLocale(), this.getDifficulty());
|
|
192
|
-
return this.createChallenge({ type: 'logic', question: logic.question, answer: logic.answer }) as LogicCaptcha;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
239
|
private generateReverse(): ReverseCaptcha {
|
|
196
240
|
const rev = ReverseGenerator.generate(this.getDifficulty());
|
|
197
241
|
return this.createChallenge({ type: 'reverse', question: rev.question, answer: rev.answer }) as ReverseCaptcha;
|
|
198
242
|
}
|
|
199
243
|
|
|
200
|
-
//
|
|
244
|
+
// randomly selects one of the available non-compound types
|
|
201
245
|
private generateMixed(): MixedCaptcha {
|
|
202
|
-
const types
|
|
246
|
+
const types = ['math', 'text', 'sequence', 'scramble', 'reverse'] as const;
|
|
203
247
|
const buffer = randomBytes(1) as any;
|
|
204
248
|
const randomType = types[buffer[0]! % types.length]!;
|
|
205
249
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
250
|
+
// resolve the raw answer directly so we can pass it to createChallenge;
|
|
251
|
+
// avoids mutating this.standardOptions and prevents race conditions
|
|
252
|
+
let question: string;
|
|
253
|
+
let answer: string | number;
|
|
254
|
+
|
|
255
|
+
if (randomType === 'math') {
|
|
256
|
+
const difficulty = this.getDifficulty();
|
|
257
|
+
let num1 = Random.getRandomNumber(difficulty);
|
|
258
|
+
let num2 = Random.getRandomNumber(difficulty);
|
|
259
|
+
const operator = Random.getRandomOperator();
|
|
260
|
+
if (operator === '/' && num2 === 0) {
|
|
261
|
+
num2 = Random.getRandomNumber(difficulty) + 1;
|
|
262
|
+
}
|
|
263
|
+
const result = this.calculateMath(num1, operator, num2);
|
|
264
|
+
if (isNaN(result) || !isFinite(result)) {
|
|
265
|
+
num1 = Random.getRandomNumber(difficulty);
|
|
266
|
+
num2 = Random.getRandomNumber(difficulty) + 1;
|
|
267
|
+
question = `${num1} + ${num2}`;
|
|
268
|
+
answer = num1 + num2;
|
|
269
|
+
} else {
|
|
270
|
+
question = `${num1} ${operator} ${num2}`;
|
|
271
|
+
answer = result;
|
|
272
|
+
}
|
|
273
|
+
} else if (randomType === 'text') {
|
|
274
|
+
const text = Random.generateRandomString(this.getDifficulty());
|
|
275
|
+
question = text;
|
|
276
|
+
answer = text;
|
|
277
|
+
} else if (randomType === 'sequence') {
|
|
278
|
+
const seq = SequenceGenerator.generate(this.getDifficulty());
|
|
279
|
+
question = seq.question;
|
|
280
|
+
answer = seq.answer;
|
|
281
|
+
} else if (randomType === 'scramble') {
|
|
282
|
+
const scr = ScrambleGenerator.generate(this.getDifficulty());
|
|
283
|
+
question = scr.question;
|
|
284
|
+
answer = scr.answer;
|
|
285
|
+
} else {
|
|
286
|
+
const rev = ReverseGenerator.generate(this.getDifficulty());
|
|
287
|
+
question = rev.question;
|
|
288
|
+
answer = rev.answer;
|
|
216
289
|
}
|
|
217
290
|
|
|
218
|
-
return this.createChallenge({
|
|
291
|
+
return this.createChallenge({ type: 'mixed', question, answer }) as MixedCaptcha;
|
|
219
292
|
}
|
|
220
293
|
|
|
221
|
-
//
|
|
294
|
+
// two-step captcha: math + scramble, both must be solved correctly.
|
|
295
|
+
// Child challenges are built via the same salt/hash pipeline but are NOT inserted into the
|
|
296
|
+
// nonce store independently, so they cannot be validated as standalone challenges.
|
|
222
297
|
private generateMulti(): CaptchaChallenge {
|
|
223
|
-
const
|
|
224
|
-
const
|
|
298
|
+
const mathRaw = this.buildMathRecord();
|
|
299
|
+
const scrambleRaw = this.buildScrambleRecord();
|
|
225
300
|
|
|
226
301
|
return this.createChallenge({
|
|
227
302
|
type: 'multi',
|
|
228
303
|
question: 'Complete two steps',
|
|
229
304
|
answer: '',
|
|
230
|
-
steps: [
|
|
305
|
+
steps: [mathRaw, scrambleRaw]
|
|
231
306
|
});
|
|
232
307
|
}
|
|
308
|
+
|
|
309
|
+
// Builds a StoredChallenge for a math question WITHOUT registering it in the nonce store.
|
|
310
|
+
private buildMathRecord(): StoredChallenge {
|
|
311
|
+
const difficulty = this.getDifficulty();
|
|
312
|
+
let num1 = Random.getRandomNumber(difficulty);
|
|
313
|
+
let num2 = Random.getRandomNumber(difficulty);
|
|
314
|
+
const operator = Random.getRandomOperator();
|
|
315
|
+
|
|
316
|
+
if (operator === '/' && num2 === 0) {
|
|
317
|
+
num2 = Random.getRandomNumber(difficulty) + 1;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
let answer = this.calculateMath(num1, operator, num2);
|
|
321
|
+
|
|
322
|
+
if (isNaN(answer) || !isFinite(answer)) {
|
|
323
|
+
num1 = Random.getRandomNumber(difficulty);
|
|
324
|
+
num2 = Random.getRandomNumber(difficulty) + 1;
|
|
325
|
+
answer = num1 + num2;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return this.buildStoredRecord({ type: 'math', question: `${num1} ${operator} ${num2}`, answer });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Builds a StoredChallenge for a scramble question WITHOUT registering it in the nonce store.
|
|
332
|
+
private buildScrambleRecord(): StoredChallenge {
|
|
333
|
+
const scr = ScrambleGenerator.generate(this.getDifficulty());
|
|
334
|
+
return this.buildStoredRecord({ type: 'scramble', question: scr.question, answer: scr.answer });
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Hashes and packages a challenge record but does NOT add it to the nonce store.
|
|
338
|
+
// Used for child steps that must not be independently redeemable.
|
|
339
|
+
private buildStoredRecord(base: Omit<StoredChallenge, 'nonce' | 'expiry' | 'hashedAnswer' | 'salt'>): StoredChallenge {
|
|
340
|
+
const nonce = Random.generateNonce();
|
|
341
|
+
const expiry = Date.now() + 5 * 60 * 1000;
|
|
342
|
+
const salt = Random.generateSalt();
|
|
343
|
+
const answerStr = typeof base.answer === 'number'
|
|
344
|
+
? (Number.isInteger(base.answer) ? base.answer.toString() : base.answer.toFixed(2))
|
|
345
|
+
: base.answer.toString();
|
|
346
|
+
const hashedAnswer = createHash('sha256').update(answerStr + salt).digest('hex');
|
|
347
|
+
return { ...base, nonce, expiry, hashedAnswer, salt };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// generates an SVG-based visual CAPTCHA immune to trivial OCR attacks
|
|
351
|
+
private generateImage(): ImageCaptcha {
|
|
352
|
+
const result = ImageGenerator.generate(this.getDifficulty());
|
|
353
|
+
return this.createChallenge({
|
|
354
|
+
type: 'image',
|
|
355
|
+
question: result.question,
|
|
356
|
+
answer: result.answer,
|
|
357
|
+
image: result.image
|
|
358
|
+
}) as ImageCaptcha;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// generates an emoji selection CAPTCHA: user picks all emojis from a given category
|
|
362
|
+
private generateEmoji(): EmojiCaptcha {
|
|
363
|
+
const result = EmojiGenerator.generate(this.getDifficulty());
|
|
364
|
+
return this.createChallenge({
|
|
365
|
+
type: 'emoji',
|
|
366
|
+
question: result.question,
|
|
367
|
+
answer: result.answer,
|
|
368
|
+
emojis: result.emojis,
|
|
369
|
+
category: result.category
|
|
370
|
+
}) as EmojiCaptcha;
|
|
371
|
+
}
|
|
233
372
|
}
|
|
@@ -1,12 +1,21 @@
|
|
|
1
|
-
import { createHash } from '../utils/crypto';
|
|
2
|
-
import type {
|
|
1
|
+
import { createHash, timingSafeEqual } from '../utils/crypto';
|
|
2
|
+
import type { StoredChallenge } from '../types';
|
|
3
|
+
|
|
4
|
+
// decodes a hex string to raw bytes without relying on Buffer
|
|
5
|
+
function hexToBytes(hex: string): Uint8Array {
|
|
6
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
7
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
8
|
+
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
9
|
+
}
|
|
10
|
+
return bytes;
|
|
11
|
+
}
|
|
3
12
|
|
|
4
13
|
export class CaptchaValidator {
|
|
5
14
|
private static readonly MAX_INPUT_LENGTH = 1000;
|
|
6
15
|
private static readonly VALID_CHAR_REGEX = /^[a-zA-Z0-9\s\-çÇğĞıİöÖşŞüÜ.,'!?]*$/;
|
|
7
16
|
|
|
8
17
|
// main validation entry point, routes to the correct validator based on challenge type
|
|
9
|
-
static validate(challenge:
|
|
18
|
+
static validate(challenge: StoredChallenge, userInput: string): boolean {
|
|
10
19
|
if (!this.isValidInput(userInput)) {
|
|
11
20
|
return false;
|
|
12
21
|
}
|
|
@@ -19,6 +28,14 @@ export class CaptchaValidator {
|
|
|
19
28
|
return this.validateCustom(challenge, userInput);
|
|
20
29
|
}
|
|
21
30
|
|
|
31
|
+
if (challenge.type === 'image') {
|
|
32
|
+
return this.validateImage(challenge, userInput);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (challenge.type === 'emoji') {
|
|
36
|
+
return this.validateEmoji(challenge, userInput);
|
|
37
|
+
}
|
|
38
|
+
|
|
22
39
|
if (this.isNumericChallenge(challenge)) {
|
|
23
40
|
return this.validateNumeric(challenge, userInput);
|
|
24
41
|
}
|
|
@@ -37,7 +54,7 @@ export class CaptchaValidator {
|
|
|
37
54
|
}
|
|
38
55
|
|
|
39
56
|
// validates multi step captchas by checking each step individually
|
|
40
|
-
private static validateMulti(challenge:
|
|
57
|
+
private static validateMulti(challenge: StoredChallenge, userInput: string): boolean {
|
|
41
58
|
if (!challenge.steps || challenge.steps.length === 0) {
|
|
42
59
|
return false;
|
|
43
60
|
}
|
|
@@ -71,22 +88,33 @@ export class CaptchaValidator {
|
|
|
71
88
|
return true;
|
|
72
89
|
}
|
|
73
90
|
|
|
74
|
-
private static isNumericChallenge(challenge:
|
|
75
|
-
|
|
91
|
+
private static isNumericChallenge(challenge: StoredChallenge): boolean {
|
|
92
|
+
// sequence can return string answers (e.g. letters for medium difficulty), so check the actual answer type
|
|
93
|
+
return challenge.type === 'math' ||
|
|
94
|
+
(challenge.type === 'sequence' && typeof challenge.answer === 'number') ||
|
|
95
|
+
(challenge.type === 'mixed' && typeof challenge.answer === 'number');
|
|
76
96
|
}
|
|
77
97
|
|
|
78
|
-
private static validateNumeric(challenge:
|
|
79
|
-
|
|
98
|
+
private static validateNumeric(challenge: StoredChallenge, userInput: string): boolean {
|
|
99
|
+
// strict: only an optional minus, digits, and an optional single decimal point
|
|
100
|
+
// rejects scientific notation, leading zeros, and partial-parse tricks like "10abc"
|
|
101
|
+
if (!/^-?(\d+(\.\d+)?|\.\d+)$/.test(userInput.trim())) {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const inputNum = parseFloat(userInput.trim());
|
|
80
106
|
|
|
81
|
-
// make sure we got a valid number, not NaN or Infinity
|
|
82
107
|
if (isNaN(inputNum) || !isFinite(inputNum)) {
|
|
83
108
|
return false;
|
|
84
109
|
}
|
|
85
110
|
|
|
86
|
-
|
|
111
|
+
// normalize to the same canonical form used at generation time (2 decimal places for floats)
|
|
112
|
+
const canonical = Number.isInteger(inputNum) ? inputNum.toString() : inputNum.toFixed(2);
|
|
113
|
+
|
|
114
|
+
return this.verifyAnswer(challenge, canonical);
|
|
87
115
|
}
|
|
88
116
|
|
|
89
|
-
private static validateText(challenge:
|
|
117
|
+
private static validateText(challenge: StoredChallenge, userInput: string): boolean {
|
|
90
118
|
const sanitized = userInput.trim();
|
|
91
119
|
|
|
92
120
|
if (!this.VALID_CHAR_REGEX.test(sanitized)) {
|
|
@@ -96,16 +124,26 @@ export class CaptchaValidator {
|
|
|
96
124
|
return this.verifyAnswer(challenge, sanitized);
|
|
97
125
|
}
|
|
98
126
|
|
|
99
|
-
//
|
|
100
|
-
|
|
127
|
+
// hash the user input with the same salt and compare using a constant-time
|
|
128
|
+
// equality check to eliminate timing side-channels
|
|
129
|
+
private static verifyAnswer(challenge: StoredChallenge, userInput: string): boolean {
|
|
101
130
|
const userHash = createHash('sha256')
|
|
102
131
|
.update(userInput + challenge.salt)
|
|
103
132
|
.digest('hex');
|
|
104
133
|
|
|
105
|
-
|
|
134
|
+
// both hex strings must be equal length before byte comparison;
|
|
135
|
+
// SHA-256 hex is always 64 chars so this only fails on corruption
|
|
136
|
+
if (userHash.length !== challenge.hashedAnswer.length) {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return timingSafeEqual(
|
|
141
|
+
hexToBytes(userHash),
|
|
142
|
+
hexToBytes(challenge.hashedAnswer)
|
|
143
|
+
);
|
|
106
144
|
}
|
|
107
145
|
|
|
108
|
-
private static validateCustom(challenge:
|
|
146
|
+
private static validateCustom(challenge: StoredChallenge, userInput: string): boolean {
|
|
109
147
|
const sanitized = userInput.trim();
|
|
110
148
|
|
|
111
149
|
if (!this.VALID_CHAR_REGEX.test(sanitized)) {
|
|
@@ -114,4 +152,47 @@ export class CaptchaValidator {
|
|
|
114
152
|
|
|
115
153
|
return this.verifyAnswer(challenge, sanitized);
|
|
116
154
|
}
|
|
155
|
+
|
|
156
|
+
// image answers are case-insensitive; only alphanumeric chars are accepted
|
|
157
|
+
private static validateImage(challenge: StoredChallenge, userInput: string): boolean {
|
|
158
|
+
const sanitized = userInput.trim().toLowerCase();
|
|
159
|
+
|
|
160
|
+
if (!/^[a-z0-9]+$/.test(sanitized)) {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (sanitized.length < 1 || sanitized.length > 20) {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return this.verifyAnswer(challenge, sanitized);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// emoji answers: comma-separated zero-based indices e.g. "0,2,4"
|
|
172
|
+
// parsed, deduplicated, sorted numerically then re-joined to produce the canonical form
|
|
173
|
+
private static validateEmoji(challenge: StoredChallenge, userInput: string): boolean {
|
|
174
|
+
const trimmed = userInput.trim();
|
|
175
|
+
|
|
176
|
+
// only digits and commas are valid; reject anything else to prevent injection
|
|
177
|
+
if (!/^[0-9,]+$/.test(trimmed)) {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const parts = trimmed.split(',').filter(s => s.length > 0);
|
|
182
|
+
|
|
183
|
+
if (parts.length === 0 || parts.length > 20) {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const indices = parts.map(Number);
|
|
188
|
+
|
|
189
|
+
if (indices.some(n => isNaN(n) || n < 0 || !Number.isInteger(n))) {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// canonical form matches what EmojiGenerator stored: sorted unique indices
|
|
194
|
+
const normalized = [...new Set(indices)].sort((a, b) => a - b).join(',');
|
|
195
|
+
|
|
196
|
+
return this.verifyAnswer(challenge, normalized);
|
|
197
|
+
}
|
|
117
198
|
}
|
package/src/types/index.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
export interface K9GuardOptions {
|
|
2
|
-
type: 'math' | 'text' | '
|
|
2
|
+
type: 'math' | 'text' | 'sequence' | 'scramble' | 'reverse' | 'mixed' | 'multi' | 'image' | 'emoji';
|
|
3
3
|
difficulty: 'easy' | 'medium' | 'hard';
|
|
4
|
-
locale?: 'en' | 'tr';
|
|
5
4
|
}
|
|
6
5
|
|
|
7
6
|
export interface CustomQuestion {
|
|
@@ -15,58 +14,71 @@ export interface K9GuardCustomOptions {
|
|
|
15
14
|
questions: CustomQuestion[];
|
|
16
15
|
}
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
// Internal: full challenge record kept server-side only.
|
|
18
|
+
// answer, hashedAnswer and salt must never leave the server.
|
|
19
|
+
export interface StoredChallenge {
|
|
20
|
+
type: 'math' | 'text' | 'sequence' | 'scramble' | 'reverse' | 'mixed' | 'multi' | 'custom' | 'image' | 'emoji';
|
|
20
21
|
question: string;
|
|
21
22
|
answer: string | number;
|
|
22
23
|
nonce: string;
|
|
23
24
|
expiry: number;
|
|
24
25
|
hashedAnswer: string;
|
|
25
26
|
salt: string;
|
|
27
|
+
steps?: StoredChallenge[];
|
|
28
|
+
image?: string;
|
|
29
|
+
emojis?: string[];
|
|
30
|
+
category?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Public: safe subset sent to the client.
|
|
34
|
+
// answer, hashedAnswer and salt are intentionally omitted to prevent
|
|
35
|
+
// hash-injection attacks where an attacker forges hashedAnswer on the client.
|
|
36
|
+
export interface CaptchaChallenge {
|
|
37
|
+
type: StoredChallenge['type'];
|
|
38
|
+
question: string;
|
|
39
|
+
nonce: string;
|
|
40
|
+
expiry: number;
|
|
26
41
|
steps?: CaptchaChallenge[];
|
|
42
|
+
image?: string;
|
|
43
|
+
emojis?: string[];
|
|
44
|
+
category?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ImageCaptcha extends CaptchaChallenge {
|
|
48
|
+
type: 'image';
|
|
49
|
+
image: string;
|
|
27
50
|
}
|
|
28
51
|
|
|
29
52
|
export interface MathCaptcha extends CaptchaChallenge {
|
|
30
53
|
type: 'math';
|
|
31
|
-
answer: number;
|
|
32
54
|
}
|
|
33
55
|
|
|
34
56
|
export interface TextCaptcha extends CaptchaChallenge {
|
|
35
57
|
type: 'text';
|
|
36
|
-
answer: string;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export interface RiddleCaptcha extends CaptchaChallenge {
|
|
40
|
-
type: 'riddle';
|
|
41
|
-
answer: string;
|
|
42
58
|
}
|
|
43
59
|
|
|
44
60
|
export interface SequenceCaptcha extends CaptchaChallenge {
|
|
45
61
|
type: 'sequence';
|
|
46
|
-
answer: string | number;
|
|
47
62
|
}
|
|
48
63
|
|
|
49
64
|
export interface ScrambleCaptcha extends CaptchaChallenge {
|
|
50
65
|
type: 'scramble';
|
|
51
|
-
answer: string;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export interface LogicCaptcha extends CaptchaChallenge {
|
|
55
|
-
type: 'logic';
|
|
56
|
-
answer: string;
|
|
57
66
|
}
|
|
58
67
|
|
|
59
68
|
export interface ReverseCaptcha extends CaptchaChallenge {
|
|
60
69
|
type: 'reverse';
|
|
61
|
-
answer: string;
|
|
62
70
|
}
|
|
63
71
|
|
|
64
72
|
export interface MixedCaptcha extends CaptchaChallenge {
|
|
65
73
|
type: 'mixed';
|
|
66
|
-
answer: string | number;
|
|
67
74
|
}
|
|
68
75
|
|
|
69
76
|
export interface CustomCaptcha extends CaptchaChallenge {
|
|
70
77
|
type: 'custom';
|
|
71
|
-
|
|
72
|
-
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface EmojiCaptcha extends CaptchaChallenge {
|
|
81
|
+
type: 'emoji';
|
|
82
|
+
emojis: string[];
|
|
83
|
+
category: string;
|
|
84
|
+
}
|