k9guard 1.0.1 → 1.0.2

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,41 @@ 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';
52
- }
53
- return this.standardOptions.locale || 'en';
55
+ // Look up the internal StoredChallenge by nonce for use during validation.
56
+ lookup(nonce: string): StoredChallenge | undefined {
57
+ return this.store.get(nonce);
54
58
  }
55
59
 
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 {
60
+ // Stores the full record server-side; returns a public CaptchaChallenge
61
+ // that is safe to send to the client (no answer, hashedAnswer or salt).
62
+ private createChallenge(base: Omit<StoredChallenge, 'nonce' | 'expiry' | 'hashedAnswer' | 'salt'>): CaptchaChallenge {
58
63
  let nonce: string;
59
64
  do {
60
- // NOTE: make sure each nonce is unique to stop replay attacks
61
65
  nonce = Random.generateNonce();
62
- } while (this.usedNonces.has(nonce));
66
+ } while (this.store.has(nonce));
63
67
 
64
- this.usedNonces.add(nonce);
68
+ // evict oldest entry before inserting to cap memory at NONCE_STORE_MAX
69
+ if (this.store.size >= NONCE_STORE_MAX) {
70
+ const oldest = this.store.keys().next().value;
71
+ if (oldest !== undefined) {
72
+ this.store.delete(oldest);
73
+ }
74
+ }
65
75
 
66
- // challenge will expire in 5 minutes
67
76
  const expiry = Date.now() + 5 * 60 * 1000;
68
77
  const salt = Random.generateSalt();
69
- // never save the real answer, only the hash for checking later
78
+ // answer is never sent to the client; only its salted hash is stored
70
79
  const hashedAnswer = createHash('sha256').update(base.answer.toString() + salt).digest('hex');
71
80
 
72
- return {
73
- ...base,
74
- nonce,
75
- expiry,
76
- hashedAnswer,
77
- salt
78
- };
81
+ const stored: StoredChallenge = { ...base, nonce, expiry, hashedAnswer, salt };
82
+ this.store.set(nonce, stored);
83
+
84
+ // strip sensitive fields before returning to caller
85
+ const { answer: _answer, hashedAnswer: _ha, salt: _salt, ...publicChallenge } = stored;
86
+ return publicChallenge as CaptchaChallenge;
79
87
  }
80
88
 
81
- // do the math based on which operator we got
89
+ // evaluate arithmetic expression for the math captcha type
82
90
  private calculateMath(a: number, op: string, b: number): number {
83
91
  if (op === '+') {
84
92
  return a + b;
@@ -91,16 +99,15 @@ export class CaptchaGenerator {
91
99
  }
92
100
  if (op === '/') {
93
101
  if (b === 0) {
94
- // can't divide by zero, return NaN
95
102
  return Number.NaN;
96
103
  }
97
- // round to 2 decimals to avoid weird floating point numbers
104
+ // round to 2 decimals to avoid floating point representation issues
98
105
  return Math.round((a / b) * 100) / 100;
99
106
  }
100
107
  return 0;
101
108
  }
102
109
 
103
- // main function that picks the right generator for the captcha type
110
+ // dispatch to the correct generator based on configured captcha type
104
111
  generate(): CaptchaChallenge {
105
112
  if (this.customOptions) {
106
113
  return this.generateCustom();
@@ -118,24 +125,24 @@ export class CaptchaGenerator {
118
125
  if (captchaType === 'text') {
119
126
  return this.generateText();
120
127
  }
121
- if (captchaType === 'riddle') {
122
- return this.generateRiddle();
123
- }
124
128
  if (captchaType === 'sequence') {
125
129
  return this.generateSequence();
126
130
  }
127
131
  if (captchaType === 'scramble') {
128
132
  return this.generateScramble();
129
133
  }
130
- if (captchaType === 'logic') {
131
- return this.generateLogic();
132
- }
133
134
  if (captchaType === 'reverse') {
134
135
  return this.generateReverse();
135
136
  }
136
137
  if (captchaType === 'multi') {
137
138
  return this.generateMulti();
138
139
  }
140
+ if (captchaType === 'image') {
141
+ return this.generateImage();
142
+ }
143
+ if (captchaType === 'emoji') {
144
+ return this.generateEmoji();
145
+ }
139
146
  return this.generateMixed();
140
147
  }
141
148
 
@@ -145,26 +152,42 @@ export class CaptchaGenerator {
145
152
  }
146
153
 
147
154
  const custom = this.customGenerator.generate();
155
+
156
+ // an empty answer would allow bypass with any blank input; reject early
157
+ if (!custom.question || !custom.answer) {
158
+ throw new Error('Custom question pool returned an empty question or answer');
159
+ }
160
+
148
161
  return this.createChallenge({ type: 'custom', question: custom.question, answer: custom.answer }) as CustomCaptcha;
149
162
  }
150
163
 
151
164
  private generateMath(): MathCaptcha {
152
- let num1 = Random.getRandomNumber(this.getDifficulty());
153
- let num2 = Random.getRandomNumber(this.getDifficulty());
165
+ const difficulty = this.getDifficulty();
166
+ let num1 = Random.getRandomNumber(difficulty);
167
+ let num2 = Random.getRandomNumber(difficulty);
154
168
  const operator = Random.getRandomOperator();
155
169
 
170
+ // guarantee non-zero divisor; add 1 so the result is always >= 1
156
171
  if (operator === '/' && num2 === 0) {
157
- num2 = Random.getRandomNumber(this.getDifficulty()) + 1;
172
+ num2 = Random.getRandomNumber(difficulty) + 1;
158
173
  }
159
174
 
160
- const question = `${num1} ${operator} ${num2}`;
161
175
  const answer = this.calculateMath(num1, operator, num2);
162
176
 
163
- if (isNaN(answer)) {
164
- return this.generateMath();
177
+ // calculateMath only returns NaN for division by zero, which is already
178
+ // prevented above — but guard here avoids any future regression without
179
+ // unbounded recursion: iterate instead of recurse
180
+ if (isNaN(answer) || !isFinite(answer)) {
181
+ num1 = Random.getRandomNumber(difficulty);
182
+ num2 = Random.getRandomNumber(difficulty) + 1;
183
+ return this.createChallenge({
184
+ type: 'math',
185
+ question: `${num1} + ${num2}`,
186
+ answer: num1 + num2
187
+ }) as MathCaptcha;
165
188
  }
166
189
 
167
- return this.createChallenge({ type: 'math', question, answer }) as MathCaptcha;
190
+ return this.createChallenge({ type: 'math', question: `${num1} ${operator} ${num2}`, answer }) as MathCaptcha;
168
191
  }
169
192
 
170
193
  private generateText(): TextCaptcha {
@@ -172,11 +195,6 @@ export class CaptchaGenerator {
172
195
  return this.createChallenge({ type: 'text', question: text, answer: text }) as TextCaptcha;
173
196
  }
174
197
 
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
198
  private generateSequence(): SequenceCaptcha {
181
199
  const seq = SequenceGenerator.generate(this.getDifficulty());
182
200
  return this.createChallenge({ type: 'sequence', question: seq.question, answer: seq.answer }) as SequenceCaptcha;
@@ -187,41 +205,65 @@ export class CaptchaGenerator {
187
205
  return this.createChallenge({ type: 'scramble', question: scr.question, answer: scr.answer }) as ScrambleCaptcha;
188
206
  }
189
207
 
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
208
  private generateReverse(): ReverseCaptcha {
196
209
  const rev = ReverseGenerator.generate(this.getDifficulty());
197
210
  return this.createChallenge({ type: 'reverse', question: rev.question, answer: rev.answer }) as ReverseCaptcha;
198
211
  }
199
212
 
200
- // generates a mixed captcha by randomly selecting a type and generating accordingly
213
+ // randomly selects one of the available non-compound types
201
214
  private generateMixed(): MixedCaptcha {
202
- const types: ('math' | 'text' | 'riddle' | 'sequence' | 'scramble' | 'logic' | 'reverse')[] = ['math', 'text', 'riddle', 'sequence', 'scramble', 'logic', 'reverse'];
215
+ const types = ['math', 'text', 'sequence', 'scramble', 'reverse'] as const;
203
216
  const buffer = randomBytes(1) as any;
204
217
  const randomType = types[buffer[0]! % types.length]!;
205
218
 
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;
219
+ // resolve the raw answer directly so we can pass it to createChallenge;
220
+ // avoids mutating this.standardOptions and prevents race conditions
221
+ let question: string;
222
+ let answer: string | number;
223
+
224
+ if (randomType === 'math') {
225
+ const difficulty = this.getDifficulty();
226
+ let num1 = Random.getRandomNumber(difficulty);
227
+ let num2 = Random.getRandomNumber(difficulty);
228
+ const operator = Random.getRandomOperator();
229
+ if (operator === '/' && num2 === 0) {
230
+ num2 = Random.getRandomNumber(difficulty) + 1;
231
+ }
232
+ const result = this.calculateMath(num1, operator, num2);
233
+ if (isNaN(result) || !isFinite(result)) {
234
+ num1 = Random.getRandomNumber(difficulty);
235
+ num2 = Random.getRandomNumber(difficulty) + 1;
236
+ question = `${num1} + ${num2}`;
237
+ answer = num1 + num2;
238
+ } else {
239
+ question = `${num1} ${operator} ${num2}`;
240
+ answer = result;
241
+ }
242
+ } else if (randomType === 'text') {
243
+ const text = Random.generateRandomString(this.getDifficulty());
244
+ question = text;
245
+ answer = text;
246
+ } else if (randomType === 'sequence') {
247
+ const seq = SequenceGenerator.generate(this.getDifficulty());
248
+ question = seq.question;
249
+ answer = seq.answer;
250
+ } else if (randomType === 'scramble') {
251
+ const scr = ScrambleGenerator.generate(this.getDifficulty());
252
+ question = scr.question;
253
+ answer = scr.answer;
254
+ } else {
255
+ const rev = ReverseGenerator.generate(this.getDifficulty());
256
+ question = rev.question;
257
+ answer = rev.answer;
216
258
  }
217
259
 
218
- return this.createChallenge({ ...challenge, type: 'mixed' }) as MixedCaptcha;
260
+ return this.createChallenge({ type: 'mixed', question, answer }) as MixedCaptcha;
219
261
  }
220
262
 
221
- // make a two-step captcha with math and riddle
263
+ // two-step captcha: math + scramble, both must be solved correctly
222
264
  private generateMulti(): CaptchaChallenge {
223
- const step1 = this.generateMath();
224
- const step2 = this.generateRiddle();
265
+ const step1 = this.store.get(this.generateMath().nonce)!;
266
+ const step2 = this.store.get(this.generateScramble().nonce)!;
225
267
 
226
268
  return this.createChallenge({
227
269
  type: 'multi',
@@ -230,4 +272,27 @@ export class CaptchaGenerator {
230
272
  steps: [step1, step2]
231
273
  });
232
274
  }
275
+
276
+ // generates an SVG-based visual CAPTCHA immune to trivial OCR attacks
277
+ private generateImage(): ImageCaptcha {
278
+ const result = ImageGenerator.generate(this.getDifficulty());
279
+ return this.createChallenge({
280
+ type: 'image',
281
+ question: result.question,
282
+ answer: result.answer,
283
+ image: result.image
284
+ }) as ImageCaptcha;
285
+ }
286
+
287
+ // generates an emoji selection CAPTCHA: user picks all emojis from a given category
288
+ private generateEmoji(): EmojiCaptcha {
289
+ const result = EmojiGenerator.generate(this.getDifficulty());
290
+ return this.createChallenge({
291
+ type: 'emoji',
292
+ question: result.question,
293
+ answer: result.answer,
294
+ emojis: result.emojis,
295
+ category: result.category
296
+ }) as EmojiCaptcha;
297
+ }
233
298
  }
@@ -1,12 +1,13 @@
1
1
  import { createHash } from '../utils/crypto';
2
- import type { CaptchaChallenge } from '../types';
2
+ import { timingSafeEqual } from 'node:crypto';
3
+ import type { StoredChallenge } from '../types';
3
4
 
4
5
  export class CaptchaValidator {
5
6
  private static readonly MAX_INPUT_LENGTH = 1000;
6
7
  private static readonly VALID_CHAR_REGEX = /^[a-zA-Z0-9\s\-çÇğĞıİöÖşŞüÜ.,'!?]*$/;
7
8
 
8
9
  // main validation entry point, routes to the correct validator based on challenge type
9
- static validate(challenge: CaptchaChallenge, userInput: string): boolean {
10
+ static validate(challenge: StoredChallenge, userInput: string): boolean {
10
11
  if (!this.isValidInput(userInput)) {
11
12
  return false;
12
13
  }
@@ -19,6 +20,14 @@ export class CaptchaValidator {
19
20
  return this.validateCustom(challenge, userInput);
20
21
  }
21
22
 
23
+ if (challenge.type === 'image') {
24
+ return this.validateImage(challenge, userInput);
25
+ }
26
+
27
+ if (challenge.type === 'emoji') {
28
+ return this.validateEmoji(challenge, userInput);
29
+ }
30
+
22
31
  if (this.isNumericChallenge(challenge)) {
23
32
  return this.validateNumeric(challenge, userInput);
24
33
  }
@@ -37,7 +46,7 @@ export class CaptchaValidator {
37
46
  }
38
47
 
39
48
  // validates multi step captchas by checking each step individually
40
- private static validateMulti(challenge: CaptchaChallenge, userInput: string): boolean {
49
+ private static validateMulti(challenge: StoredChallenge, userInput: string): boolean {
41
50
  if (!challenge.steps || challenge.steps.length === 0) {
42
51
  return false;
43
52
  }
@@ -71,11 +80,14 @@ export class CaptchaValidator {
71
80
  return true;
72
81
  }
73
82
 
74
- private static isNumericChallenge(challenge: CaptchaChallenge): boolean {
75
- return challenge.type === 'math' || challenge.type === 'sequence' || (challenge.type === 'mixed' && typeof challenge.answer === 'number');
83
+ private static isNumericChallenge(challenge: StoredChallenge): boolean {
84
+ // sequence can return string answers (e.g. letters for medium difficulty), so check the actual answer type
85
+ return challenge.type === 'math' ||
86
+ (challenge.type === 'sequence' && typeof challenge.answer === 'number') ||
87
+ (challenge.type === 'mixed' && typeof challenge.answer === 'number');
76
88
  }
77
89
 
78
- private static validateNumeric(challenge: CaptchaChallenge, userInput: string): boolean {
90
+ private static validateNumeric(challenge: StoredChallenge, userInput: string): boolean {
79
91
  const inputNum = parseFloat(userInput);
80
92
 
81
93
  // make sure we got a valid number, not NaN or Infinity
@@ -86,7 +98,7 @@ export class CaptchaValidator {
86
98
  return this.verifyAnswer(challenge, inputNum.toString());
87
99
  }
88
100
 
89
- private static validateText(challenge: CaptchaChallenge, userInput: string): boolean {
101
+ private static validateText(challenge: StoredChallenge, userInput: string): boolean {
90
102
  const sanitized = userInput.trim();
91
103
 
92
104
  if (!this.VALID_CHAR_REGEX.test(sanitized)) {
@@ -96,16 +108,26 @@ export class CaptchaValidator {
96
108
  return this.verifyAnswer(challenge, sanitized);
97
109
  }
98
110
 
99
- // NOTE: hash the user input with same salt and compare to stored hash
100
- private static verifyAnswer(challenge: CaptchaChallenge, userInput: string): boolean {
111
+ // hash the user input with the same salt and compare using a constant-time
112
+ // equality check to eliminate timing side-channels
113
+ private static verifyAnswer(challenge: StoredChallenge, userInput: string): boolean {
101
114
  const userHash = createHash('sha256')
102
115
  .update(userInput + challenge.salt)
103
116
  .digest('hex');
104
117
 
105
- return userHash === challenge.hashedAnswer;
118
+ // both buffers must be the same length for timingSafeEqual; hex-encoded
119
+ // SHA-256 is always 64 chars so this holds unless hashedAnswer is corrupted
120
+ if (userHash.length !== challenge.hashedAnswer.length) {
121
+ return false;
122
+ }
123
+
124
+ return timingSafeEqual(
125
+ Buffer.from(userHash, 'hex'),
126
+ Buffer.from(challenge.hashedAnswer, 'hex')
127
+ );
106
128
  }
107
129
 
108
- private static validateCustom(challenge: CaptchaChallenge, userInput: string): boolean {
130
+ private static validateCustom(challenge: StoredChallenge, userInput: string): boolean {
109
131
  const sanitized = userInput.trim();
110
132
 
111
133
  if (!this.VALID_CHAR_REGEX.test(sanitized)) {
@@ -114,4 +136,47 @@ export class CaptchaValidator {
114
136
 
115
137
  return this.verifyAnswer(challenge, sanitized);
116
138
  }
139
+
140
+ // image answers are case-insensitive; only alphanumeric chars are accepted
141
+ private static validateImage(challenge: StoredChallenge, userInput: string): boolean {
142
+ const sanitized = userInput.trim().toLowerCase();
143
+
144
+ if (!/^[a-z0-9]+$/.test(sanitized)) {
145
+ return false;
146
+ }
147
+
148
+ if (sanitized.length < 1 || sanitized.length > 20) {
149
+ return false;
150
+ }
151
+
152
+ return this.verifyAnswer(challenge, sanitized);
153
+ }
154
+
155
+ // emoji answers: comma-separated zero-based indices e.g. "0,2,4"
156
+ // parsed, deduplicated, sorted numerically then re-joined to produce the canonical form
157
+ private static validateEmoji(challenge: StoredChallenge, userInput: string): boolean {
158
+ const trimmed = userInput.trim();
159
+
160
+ // only digits and commas are valid; reject anything else to prevent injection
161
+ if (!/^[0-9,]+$/.test(trimmed)) {
162
+ return false;
163
+ }
164
+
165
+ const parts = trimmed.split(',').filter(s => s.length > 0);
166
+
167
+ if (parts.length === 0 || parts.length > 20) {
168
+ return false;
169
+ }
170
+
171
+ const indices = parts.map(Number);
172
+
173
+ if (indices.some(n => isNaN(n) || n < 0 || !Number.isInteger(n))) {
174
+ return false;
175
+ }
176
+
177
+ // canonical form matches what EmojiGenerator stored: sorted unique indices
178
+ const normalized = [...new Set(indices)].sort((a, b) => a - b).join(',');
179
+
180
+ return this.verifyAnswer(challenge, normalized);
181
+ }
117
182
  }
@@ -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
+ }