k9guard 1.0.3 → 1.0.4
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 +196 -5
- package/dist/index.cjs +1555 -0
- package/dist/index.d.cts +141 -0
- package/dist/index.d.ts +141 -0
- package/dist/index.js +1526 -0
- package/docs/tr/README.md +196 -5
- package/package.json +35 -13
- package/index.ts +0 -4
- package/src/K9Guard.ts +0 -97
- package/src/core/captchaGenerator.ts +0 -372
- package/src/core/captchaValidator.ts +0 -198
- package/src/types/index.ts +0 -84
- package/src/utils/crypto.ts +0 -336
- package/src/utils/customQuestionGenerator.ts +0 -40
- package/src/utils/emojiGenerator.ts +0 -88
- package/src/utils/imageGenerator.ts +0 -154
- package/src/utils/random.ts +0 -43
- package/src/utils/reverseGenerator.ts +0 -54
- package/src/utils/scrambleGenerator.ts +0 -49
- package/src/utils/sequenceGenerator.ts +0 -34
- package/src/validators/customQuestionValidator.ts +0 -88
- package/tsconfig.json +0 -29
|
@@ -1,372 +0,0 @@
|
|
|
1
|
-
import { createHash, randomBytes } from '../utils/crypto';
|
|
2
|
-
import { Random } from '../utils/random';
|
|
3
|
-
import { SequenceGenerator } from '../utils/sequenceGenerator';
|
|
4
|
-
import { ScrambleGenerator } from '../utils/scrambleGenerator';
|
|
5
|
-
import { ReverseGenerator } from '../utils/reverseGenerator';
|
|
6
|
-
import { CustomQuestionGenerator } from '../utils/customQuestionGenerator';
|
|
7
|
-
import { CustomQuestionValidator } from '../validators/customQuestionValidator';
|
|
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;
|
|
15
|
-
|
|
16
|
-
export class CaptchaGenerator {
|
|
17
|
-
private standardOptions: K9GuardOptions | null = null;
|
|
18
|
-
private customOptions: K9GuardCustomOptions | null = null;
|
|
19
|
-
private customGenerator: CustomQuestionGenerator | null = null;
|
|
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();
|
|
23
|
-
|
|
24
|
-
// set up the generator and check custom questions if they exist
|
|
25
|
-
constructor(options: K9GuardOptions | K9GuardCustomOptions) {
|
|
26
|
-
if (this.isCustomOptions(options)) {
|
|
27
|
-
this.customOptions = options;
|
|
28
|
-
const validation = CustomQuestionValidator.validate(options.questions);
|
|
29
|
-
if (!validation.valid) {
|
|
30
|
-
throw new Error(`Invalid custom questions: ${validation.error}`);
|
|
31
|
-
}
|
|
32
|
-
const sanitized = CustomQuestionValidator.sanitize(options.questions);
|
|
33
|
-
this.customGenerator = new CustomQuestionGenerator(sanitized);
|
|
34
|
-
} else {
|
|
35
|
-
this.standardOptions = options;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// check if we got custom captcha options
|
|
40
|
-
private isCustomOptions(options: unknown): options is K9GuardCustomOptions {
|
|
41
|
-
if (typeof options !== 'object' || options === null) {
|
|
42
|
-
return false;
|
|
43
|
-
}
|
|
44
|
-
const opt = options as Record<string, unknown>;
|
|
45
|
-
return opt.type === 'custom' && Array.isArray(opt.questions);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
private getDifficulty(): 'easy' | 'medium' | 'hard' {
|
|
49
|
-
if (!this.standardOptions) {
|
|
50
|
-
return 'easy';
|
|
51
|
-
}
|
|
52
|
-
return this.standardOptions.difficulty;
|
|
53
|
-
}
|
|
54
|
-
|
|
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);
|
|
62
|
-
}
|
|
63
|
-
return record;
|
|
64
|
-
}
|
|
65
|
-
|
|
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
|
-
|
|
83
|
-
let nonce: string;
|
|
84
|
-
do {
|
|
85
|
-
nonce = Random.generateNonce();
|
|
86
|
-
} while (this.store.has(nonce));
|
|
87
|
-
|
|
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
|
-
}
|
|
95
|
-
|
|
96
|
-
const expiry = Date.now() + 5 * 60 * 1000;
|
|
97
|
-
const salt = Random.generateSalt();
|
|
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
|
-
}
|
|
115
|
-
|
|
116
|
-
return publicChallenge;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// evaluate arithmetic expression for the math captcha type
|
|
120
|
-
private calculateMath(a: number, op: string, b: number): number {
|
|
121
|
-
if (op === '+') {
|
|
122
|
-
return a + b;
|
|
123
|
-
}
|
|
124
|
-
if (op === '-') {
|
|
125
|
-
return a - b;
|
|
126
|
-
}
|
|
127
|
-
if (op === '*') {
|
|
128
|
-
return a * b;
|
|
129
|
-
}
|
|
130
|
-
if (op === '/') {
|
|
131
|
-
if (b === 0) {
|
|
132
|
-
return Number.NaN;
|
|
133
|
-
}
|
|
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));
|
|
137
|
-
}
|
|
138
|
-
return 0;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// dispatch to the correct generator based on configured captcha type
|
|
142
|
-
generate(): CaptchaChallenge {
|
|
143
|
-
if (this.customOptions) {
|
|
144
|
-
return this.generateCustom();
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
if (!this.standardOptions) {
|
|
148
|
-
throw new Error('Generator not properly initialized');
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const captchaType = this.standardOptions.type;
|
|
152
|
-
|
|
153
|
-
if (captchaType === 'math') {
|
|
154
|
-
return this.generateMath();
|
|
155
|
-
}
|
|
156
|
-
if (captchaType === 'text') {
|
|
157
|
-
return this.generateText();
|
|
158
|
-
}
|
|
159
|
-
if (captchaType === 'sequence') {
|
|
160
|
-
return this.generateSequence();
|
|
161
|
-
}
|
|
162
|
-
if (captchaType === 'scramble') {
|
|
163
|
-
return this.generateScramble();
|
|
164
|
-
}
|
|
165
|
-
if (captchaType === 'reverse') {
|
|
166
|
-
return this.generateReverse();
|
|
167
|
-
}
|
|
168
|
-
if (captchaType === 'multi') {
|
|
169
|
-
return this.generateMulti();
|
|
170
|
-
}
|
|
171
|
-
if (captchaType === 'image') {
|
|
172
|
-
return this.generateImage();
|
|
173
|
-
}
|
|
174
|
-
if (captchaType === 'emoji') {
|
|
175
|
-
return this.generateEmoji();
|
|
176
|
-
}
|
|
177
|
-
return this.generateMixed();
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
private generateCustom(): CustomCaptcha {
|
|
181
|
-
if (!this.customGenerator) {
|
|
182
|
-
throw new Error('Custom generator not initialized');
|
|
183
|
-
}
|
|
184
|
-
|
|
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
|
-
|
|
192
|
-
return this.createChallenge({ type: 'custom', question: custom.question, answer: custom.answer }) as CustomCaptcha;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
private generateMath(): MathCaptcha {
|
|
196
|
-
const difficulty = this.getDifficulty();
|
|
197
|
-
let num1 = Random.getRandomNumber(difficulty);
|
|
198
|
-
let num2 = Random.getRandomNumber(difficulty);
|
|
199
|
-
const operator = Random.getRandomOperator();
|
|
200
|
-
|
|
201
|
-
// guarantee non-zero divisor; add 1 so the result is always >= 1
|
|
202
|
-
if (operator === '/' && num2 === 0) {
|
|
203
|
-
num2 = Random.getRandomNumber(difficulty) + 1;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
const answer = this.calculateMath(num1, operator, num2);
|
|
207
|
-
|
|
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;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
return this.createChallenge({ type: 'math', question: `${num1} ${operator} ${num2}`, answer }) as MathCaptcha;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
private generateText(): TextCaptcha {
|
|
225
|
-
const text = Random.generateRandomString(this.getDifficulty());
|
|
226
|
-
return this.createChallenge({ type: 'text', question: text, answer: text }) as TextCaptcha;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
private generateSequence(): SequenceCaptcha {
|
|
230
|
-
const seq = SequenceGenerator.generate(this.getDifficulty());
|
|
231
|
-
return this.createChallenge({ type: 'sequence', question: seq.question, answer: seq.answer }) as SequenceCaptcha;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
private generateScramble(): ScrambleCaptcha {
|
|
235
|
-
const scr = ScrambleGenerator.generate(this.getDifficulty());
|
|
236
|
-
return this.createChallenge({ type: 'scramble', question: scr.question, answer: scr.answer }) as ScrambleCaptcha;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
private generateReverse(): ReverseCaptcha {
|
|
240
|
-
const rev = ReverseGenerator.generate(this.getDifficulty());
|
|
241
|
-
return this.createChallenge({ type: 'reverse', question: rev.question, answer: rev.answer }) as ReverseCaptcha;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// randomly selects one of the available non-compound types
|
|
245
|
-
private generateMixed(): MixedCaptcha {
|
|
246
|
-
const types = ['math', 'text', 'sequence', 'scramble', 'reverse'] as const;
|
|
247
|
-
const buffer = randomBytes(1) as any;
|
|
248
|
-
const randomType = types[buffer[0]! % types.length]!;
|
|
249
|
-
|
|
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;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
return this.createChallenge({ type: 'mixed', question, answer }) as MixedCaptcha;
|
|
292
|
-
}
|
|
293
|
-
|
|
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.
|
|
297
|
-
private generateMulti(): CaptchaChallenge {
|
|
298
|
-
const mathRaw = this.buildMathRecord();
|
|
299
|
-
const scrambleRaw = this.buildScrambleRecord();
|
|
300
|
-
|
|
301
|
-
return this.createChallenge({
|
|
302
|
-
type: 'multi',
|
|
303
|
-
question: 'Complete two steps',
|
|
304
|
-
answer: '',
|
|
305
|
-
steps: [mathRaw, scrambleRaw]
|
|
306
|
-
});
|
|
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
|
-
}
|
|
372
|
-
}
|
|
@@ -1,198 +0,0 @@
|
|
|
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
|
-
}
|
|
12
|
-
|
|
13
|
-
export class CaptchaValidator {
|
|
14
|
-
private static readonly MAX_INPUT_LENGTH = 1000;
|
|
15
|
-
private static readonly VALID_CHAR_REGEX = /^[a-zA-Z0-9\s\-çÇğĞıİöÖşŞüÜ.,'!?]*$/;
|
|
16
|
-
|
|
17
|
-
// main validation entry point, routes to the correct validator based on challenge type
|
|
18
|
-
static validate(challenge: StoredChallenge, userInput: string): boolean {
|
|
19
|
-
if (!this.isValidInput(userInput)) {
|
|
20
|
-
return false;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
if (challenge.type === 'multi') {
|
|
24
|
-
return this.validateMulti(challenge, userInput);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
if (challenge.type === 'custom') {
|
|
28
|
-
return this.validateCustom(challenge, userInput);
|
|
29
|
-
}
|
|
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
|
-
|
|
39
|
-
if (this.isNumericChallenge(challenge)) {
|
|
40
|
-
return this.validateNumeric(challenge, userInput);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
return this.validateText(challenge, userInput);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
private static isValidInput(input: unknown): boolean {
|
|
47
|
-
if (typeof input !== 'string') {
|
|
48
|
-
return false;
|
|
49
|
-
}
|
|
50
|
-
if (input.length === 0 || input.length > this.MAX_INPUT_LENGTH) {
|
|
51
|
-
return false;
|
|
52
|
-
}
|
|
53
|
-
return true;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// validates multi step captchas by checking each step individually
|
|
57
|
-
private static validateMulti(challenge: StoredChallenge, userInput: string): boolean {
|
|
58
|
-
if (!challenge.steps || challenge.steps.length === 0) {
|
|
59
|
-
return false;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
let parsed: unknown;
|
|
63
|
-
try {
|
|
64
|
-
// NOTE: user input should be a JSON array of answers
|
|
65
|
-
parsed = JSON.parse(userInput);
|
|
66
|
-
} catch {
|
|
67
|
-
return false;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
if (!Array.isArray(parsed) || parsed.length !== challenge.steps.length) {
|
|
71
|
-
return false;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// check each step answer matches its challenge
|
|
75
|
-
for (let i = 0; i < challenge.steps.length; i++) {
|
|
76
|
-
const step = challenge.steps[i];
|
|
77
|
-
const input = parsed[i];
|
|
78
|
-
|
|
79
|
-
if (!step || typeof input !== 'string') {
|
|
80
|
-
return false;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
if (!this.validate(step, input)) {
|
|
84
|
-
return false;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
return true;
|
|
89
|
-
}
|
|
90
|
-
|
|
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');
|
|
96
|
-
}
|
|
97
|
-
|
|
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());
|
|
106
|
-
|
|
107
|
-
if (isNaN(inputNum) || !isFinite(inputNum)) {
|
|
108
|
-
return false;
|
|
109
|
-
}
|
|
110
|
-
|
|
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);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
private static validateText(challenge: StoredChallenge, userInput: string): boolean {
|
|
118
|
-
const sanitized = userInput.trim();
|
|
119
|
-
|
|
120
|
-
if (!this.VALID_CHAR_REGEX.test(sanitized)) {
|
|
121
|
-
return false;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
return this.verifyAnswer(challenge, sanitized);
|
|
125
|
-
}
|
|
126
|
-
|
|
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 {
|
|
130
|
-
const userHash = createHash('sha256')
|
|
131
|
-
.update(userInput + challenge.salt)
|
|
132
|
-
.digest('hex');
|
|
133
|
-
|
|
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
|
-
);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
private static validateCustom(challenge: StoredChallenge, userInput: string): boolean {
|
|
147
|
-
const sanitized = userInput.trim();
|
|
148
|
-
|
|
149
|
-
if (!this.VALID_CHAR_REGEX.test(sanitized)) {
|
|
150
|
-
return false;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
return this.verifyAnswer(challenge, sanitized);
|
|
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
|
-
}
|
|
198
|
-
}
|
package/src/types/index.ts
DELETED
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
export interface K9GuardOptions {
|
|
2
|
-
type: 'math' | 'text' | 'sequence' | 'scramble' | 'reverse' | 'mixed' | 'multi' | 'image' | 'emoji';
|
|
3
|
-
difficulty: 'easy' | 'medium' | 'hard';
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
export interface CustomQuestion {
|
|
7
|
-
question: string;
|
|
8
|
-
answer: string;
|
|
9
|
-
difficulty: 'easy' | 'medium' | 'hard';
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export interface K9GuardCustomOptions {
|
|
13
|
-
type: 'custom';
|
|
14
|
-
questions: CustomQuestion[];
|
|
15
|
-
}
|
|
16
|
-
|
|
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';
|
|
21
|
-
question: string;
|
|
22
|
-
answer: string | number;
|
|
23
|
-
nonce: string;
|
|
24
|
-
expiry: number;
|
|
25
|
-
hashedAnswer: string;
|
|
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;
|
|
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;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export interface MathCaptcha extends CaptchaChallenge {
|
|
53
|
-
type: 'math';
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export interface TextCaptcha extends CaptchaChallenge {
|
|
57
|
-
type: 'text';
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export interface SequenceCaptcha extends CaptchaChallenge {
|
|
61
|
-
type: 'sequence';
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export interface ScrambleCaptcha extends CaptchaChallenge {
|
|
65
|
-
type: 'scramble';
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export interface ReverseCaptcha extends CaptchaChallenge {
|
|
69
|
-
type: 'reverse';
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export interface MixedCaptcha extends CaptchaChallenge {
|
|
73
|
-
type: 'mixed';
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export interface CustomCaptcha extends CaptchaChallenge {
|
|
77
|
-
type: 'custom';
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export interface EmojiCaptcha extends CaptchaChallenge {
|
|
81
|
-
type: 'emoji';
|
|
82
|
-
emojis: string[];
|
|
83
|
-
category: string;
|
|
84
|
-
}
|