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.
@@ -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 type { K9GuardOptions, K9GuardCustomOptions, CaptchaChallenge, MathCaptcha, TextCaptcha, RiddleCaptcha, SequenceCaptcha, ScrambleCaptcha, LogicCaptcha, ReverseCaptcha, MixedCaptcha, CustomCaptcha, CustomQuestion } from '../types';
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
- private usedNonces: Set<string> = new Set();
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
- private getLocale(): 'en' | 'tr' {
50
- if (!this.standardOptions) {
51
- return 'en';
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 this.standardOptions.locale || 'en';
63
+ return record;
54
64
  }
55
65
 
56
- // add security stuff to the challenge like unique nonce, expiry time, and hashed answer
57
- private createChallenge(base: Omit<CaptchaChallenge, 'nonce' | 'expiry' | 'hashedAnswer' | 'salt'>): CaptchaChallenge {
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.usedNonces.has(nonce));
86
+ } while (this.store.has(nonce));
63
87
 
64
- this.usedNonces.add(nonce);
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
- // never save the real answer, only the hash for checking later
70
- const hashedAnswer = createHash('sha256').update(base.answer.toString() + salt).digest('hex');
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
- // do the math based on which operator we got
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
- // round to 2 decimals to avoid weird floating point numbers
98
- return Math.round((a / b) * 100) / 100;
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
- // main function that picks the right generator for the captcha type
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
- let num1 = Random.getRandomNumber(this.getDifficulty());
153
- let num2 = Random.getRandomNumber(this.getDifficulty());
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(this.getDifficulty()) + 1;
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
- if (isNaN(answer)) {
164
- return this.generateMath();
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, answer }) as MathCaptcha;
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
- // generates a mixed captcha by randomly selecting a type and generating accordingly
244
+ // randomly selects one of the available non-compound types
201
245
  private generateMixed(): MixedCaptcha {
202
- const types: ('math' | 'text' | 'riddle' | 'sequence' | 'scramble' | 'logic' | 'reverse')[] = ['math', 'text', 'riddle', 'sequence', 'scramble', 'logic', 'reverse'];
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
- const previousType = this.standardOptions?.type;
207
- if (this.standardOptions) {
208
- // NOTE: change the type temporarily so we can call generate() again
209
- this.standardOptions.type = randomType;
210
- }
211
-
212
- const challenge = this.generate();
213
- if (this.standardOptions && previousType) {
214
- // put back the old type when done
215
- this.standardOptions.type = previousType;
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({ ...challenge, type: 'mixed' }) as MixedCaptcha;
291
+ return this.createChallenge({ type: 'mixed', question, answer }) as MixedCaptcha;
219
292
  }
220
293
 
221
- // make a two-step captcha with math and riddle
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 step1 = this.generateMath();
224
- const step2 = this.generateRiddle();
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: [step1, step2]
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 { CaptchaChallenge } from '../types';
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: CaptchaChallenge, userInput: string): boolean {
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: CaptchaChallenge, userInput: string): boolean {
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: CaptchaChallenge): boolean {
75
- return challenge.type === 'math' || challenge.type === 'sequence' || (challenge.type === 'mixed' && typeof challenge.answer === 'number');
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: CaptchaChallenge, userInput: string): boolean {
79
- const inputNum = parseFloat(userInput);
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
- return this.verifyAnswer(challenge, inputNum.toString());
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: CaptchaChallenge, userInput: string): boolean {
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
- // NOTE: hash the user input with same salt and compare to stored hash
100
- private static verifyAnswer(challenge: CaptchaChallenge, userInput: string): boolean {
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
- return userHash === challenge.hashedAnswer;
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: CaptchaChallenge, userInput: string): boolean {
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
  }
@@ -1,7 +1,6 @@
1
1
  export interface K9GuardOptions {
2
- type: 'math' | 'text' | 'riddle' | 'sequence' | 'scramble' | 'logic' | 'reverse' | 'mixed' | 'multi';
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
- export interface CaptchaChallenge {
19
- type: 'math' | 'text' | 'riddle' | 'sequence' | 'scramble' | 'logic' | 'reverse' | 'mixed' | 'multi' | 'custom';
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
- answer: string;
72
- }
78
+ }
79
+
80
+ export interface EmojiCaptcha extends CaptchaChallenge {
81
+ type: 'emoji';
82
+ emojis: string[];
83
+ category: string;
84
+ }