k9guard 1.0.2 → 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/README.md +12 -15
- package/docs/tr/README.md +12 -15
- package/package.json +1 -1
- package/src/K9Guard.ts +17 -11
- package/src/core/captchaGenerator.ts +89 -15
- package/src/core/captchaValidator.ts +25 -9
- package/src/utils/crypto.ts +186 -43
- package/src/utils/imageGenerator.ts +4 -2
- package/src/utils/random.ts +2 -2
- package/src/utils/sequenceGenerator.ts +10 -6
package/README.md
CHANGED
|
@@ -11,7 +11,9 @@ A secure, lightweight, and flexible CAPTCHA module for TypeScript/JavaScript pro
|
|
|
11
11
|
- **Cryptographically Secure**: NIST SP 800-90A compliant random generation
|
|
12
12
|
- **10 CAPTCHA Types**: Math, text, sequence, scramble, reverse, mixed, multi-step, image, emoji, and custom challenges
|
|
13
13
|
- **Security First**: SHA-256 salted hashing, server-side challenge store, nonce-based session management, and 5-minute expiry
|
|
14
|
-
- **
|
|
14
|
+
- **Single-Use Challenges**: Every nonce is consumed on the first `validate()` call — success or failure — preventing replay and brute-force attacks
|
|
15
|
+
- **Strict Configuration**: Invalid `type` or `difficulty` values throw immediately; no silent fallbacks
|
|
16
|
+
- **Input Validation**: Length limits, strict numeric parsing, type checking, and sanitization to prevent injection attacks
|
|
15
17
|
- **Custom Questions**: Support for your own questions with validation and sanitization
|
|
16
18
|
- **Zero Dependencies**: Lightweight with no external dependencies
|
|
17
19
|
- **Well Tested**: Comprehensive test coverage including edge cases and security scenarios
|
|
@@ -194,6 +196,8 @@ const isValid = captcha.validate(challenge, "paris");
|
|
|
194
196
|
|
|
195
197
|
### Constructor Options
|
|
196
198
|
|
|
199
|
+
Both `type` and `difficulty` are **required** and strictly validated. Passing an invalid value throws an error immediately.
|
|
200
|
+
|
|
197
201
|
#### Standard CAPTCHA Options
|
|
198
202
|
|
|
199
203
|
```typescript
|
|
@@ -239,25 +243,18 @@ console.log(challenge.category); // category name (only for type: 'emoji')
|
|
|
239
243
|
|
|
240
244
|
Validates user input against the stored server-side record (looked up by `challenge.nonce`). Returns `true` if correct, `false` otherwise. Tampered `hashedAnswer` or `salt` on the public challenge object have no effect.
|
|
241
245
|
|
|
246
|
+
> **⚠️ Single-use semantics:** `validate()` consumes the nonce on the **first call**, regardless of whether the answer is correct or not. After any validation attempt, the challenge is invalidated. Always call `generate()` again before presenting a new challenge to the user.
|
|
247
|
+
|
|
242
248
|
```typescript
|
|
243
249
|
const isValid = captcha.validate(challenge, userAnswer);
|
|
244
|
-
```
|
|
245
|
-
|
|
246
|
-
## Testing
|
|
247
250
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
251
|
+
// After validate(), the challenge is consumed.
|
|
252
|
+
// For a retry, generate a fresh challenge:
|
|
253
|
+
if (!isValid) {
|
|
254
|
+
const newChallenge = captcha.generate();
|
|
255
|
+
}
|
|
252
256
|
```
|
|
253
257
|
|
|
254
|
-
Tests include:
|
|
255
|
-
- All CAPTCHA types with correct/incorrect/edge case inputs
|
|
256
|
-
- Custom question validation
|
|
257
|
-
- Multi-step challenges
|
|
258
|
-
- Input sanitization
|
|
259
|
-
- Security validations
|
|
260
|
-
|
|
261
258
|
## Contributing
|
|
262
259
|
|
|
263
260
|
We welcome contributions! Here's how you can help:
|
package/docs/tr/README.md
CHANGED
|
@@ -11,7 +11,9 @@ TypeScript/JavaScript projeleri için kriptografik güvenlik sunan güvenli, haf
|
|
|
11
11
|
- **Kriptografik Güvenlik**: NIST SP 800-90A standardına uyumluluk sağlanmıştır
|
|
12
12
|
- **10 CAPTCHA Türü**: Matematik, metin, dizi, karıştırma, ters çevirme, karma, çok adımlı, görsel, emoji ve özel doğrulama yöntemleri
|
|
13
13
|
- **Güvenlik Odaklı**: SHA-256 tuzlu hash algoritması, sunucu taraflı challenge deposu, nonce tabanlı oturum yönetimi ve 5 dakikalık geçerlilik süresi
|
|
14
|
-
- **
|
|
14
|
+
- **Tek Kullanımlık Challenge**: Her nonce, `validate()` çağrısında — doğru ya da yanlış fark etmeksizin — tüketilir; replay ve brute-force saldırıları engellenir
|
|
15
|
+
- **Katı Yapılandırma**: Geçersiz `type` veya `difficulty` değerleri anında hata fırlatır; sessiz fallback yoktur
|
|
16
|
+
- **Girdi Doğrulama**: Enjeksiyon saldırılarını önlemek için uzunluk sınırlamaları, katı sayısal ayrıştırma, tip kontrolü ve sanitizasyon
|
|
15
17
|
- **Özel Sorular**: Doğrulama ve sanitizasyon ile kendi sorularınızı tanımlama desteği
|
|
16
18
|
- **Sıfır Bağımlılık**: Harici bağımlılık gerektirmeyen hafif yapı
|
|
17
19
|
- **Kapsamlı Test Edilmiş**: Uç durumlar ve güvenlik senaryoları dahil olmak üzere geniş test kapsama alanı
|
|
@@ -194,6 +196,8 @@ const isValid = captcha.validate(challenge, "ankara");
|
|
|
194
196
|
|
|
195
197
|
### Yapıcı Metod Seçenekleri
|
|
196
198
|
|
|
199
|
+
`type` ve `difficulty` alanları **zorunludur** ve katı şekilde doğrulanır. Geçersiz bir değer iletildiğinde constructor anında hata fırlatır.
|
|
200
|
+
|
|
197
201
|
#### Standart CAPTCHA Seçenekleri
|
|
198
202
|
|
|
199
203
|
```typescript
|
|
@@ -239,25 +243,18 @@ console.log(challenge.category); // kategori adı (yalnızca type: 'emoji' içi
|
|
|
239
243
|
|
|
240
244
|
Kullanıcı girdisini `challenge.nonce` üzerinden bulunan sunucu taraflı kayıtla karşılaştırır. Doğruysa `true`, yanlışsa `false` döndürür. Public challenge nesnesindeki `hashedAnswer` veya `salt` değiştirme girişimlerinin hiçbir etkisi yoktur.
|
|
241
245
|
|
|
246
|
+
> **⚠️ Tek kullanımlık semantik:** `validate()`, **ilk çağrıda** — cevap doğru ya da yanlış olsun fark etmeksizin — nonce'u tüketir. Her doğrulama denemesinden sonra challenge geçersiz hale gelir. Kullanıcıya yeni bir challenge sunmadan önce mutlaka `generate()` yeniden çağrılmalıdır.
|
|
247
|
+
|
|
242
248
|
```typescript
|
|
243
249
|
const isValid = captcha.validate(challenge, userAnswer);
|
|
244
|
-
```
|
|
245
|
-
|
|
246
|
-
## Test Etme
|
|
247
250
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
251
|
+
// validate() çağrısından sonra challenge tüketilir.
|
|
252
|
+
// Yeniden deneme için yeni bir challenge üretilmeli:
|
|
253
|
+
if (!isValid) {
|
|
254
|
+
const newChallenge = captcha.generate();
|
|
255
|
+
}
|
|
252
256
|
```
|
|
253
257
|
|
|
254
|
-
Testler şunları içerir:
|
|
255
|
-
- Tüm CAPTCHA türleri için doğru/yanlış/uç durum girdileri
|
|
256
|
-
- Özel soru doğrulama senaryoları
|
|
257
|
-
- Çok adımlı doğrulamalar
|
|
258
|
-
- Girdi sanitizasyonu
|
|
259
|
-
- Güvenlik doğrulamaları
|
|
260
|
-
|
|
261
258
|
## Katkıda Bulunma
|
|
262
259
|
|
|
263
260
|
Katkılarınızı memnuniyetle karşılıyoruz! Nasıl yardımcı olabilirsiniz:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "k9guard",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "A secure, lightweight, and flexible CAPTCHA module for TypeScript/JavaScript projects with cryptographic security and multi-language support",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"type": "module",
|
package/src/K9Guard.ts
CHANGED
|
@@ -7,7 +7,7 @@ export class K9Guard {
|
|
|
7
7
|
private options: K9GuardOptions | K9GuardCustomOptions;
|
|
8
8
|
private generator: CaptchaGenerator;
|
|
9
9
|
|
|
10
|
-
constructor(options: K9GuardOptions | K9GuardCustomOptions | { type: 'custom'; questions: CustomQuestion[] }
|
|
10
|
+
constructor(options: K9GuardOptions | K9GuardCustomOptions | { type: 'custom'; questions: CustomQuestion[] }) {
|
|
11
11
|
const processedOptions = this.processOptions(options);
|
|
12
12
|
this.generator = new CaptchaGenerator(processedOptions);
|
|
13
13
|
this.options = processedOptions;
|
|
@@ -15,7 +15,7 @@ export class K9Guard {
|
|
|
15
15
|
|
|
16
16
|
private processOptions(options: unknown): K9GuardOptions | K9GuardCustomOptions {
|
|
17
17
|
if (typeof options !== 'object' || options === null) {
|
|
18
|
-
|
|
18
|
+
throw new Error('Options must be an object');
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
const opt = options as Record<string, unknown>;
|
|
@@ -36,12 +36,19 @@ export class K9Guard {
|
|
|
36
36
|
} as K9GuardCustomOptions;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
const validTypes = ['math', 'text', 'sequence', 'scramble', 'reverse', 'mixed', 'multi', 'image', 'emoji'];
|
|
40
|
-
|
|
39
|
+
const validTypes = ['math', 'text', 'sequence', 'scramble', 'reverse', 'mixed', 'multi', 'image', 'emoji'] as const;
|
|
40
|
+
if (!validTypes.includes(opt.type as any)) {
|
|
41
|
+
throw new Error(`Invalid type. Must be one of: ${validTypes.join(', ')}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const validDifficulties = ['easy', 'medium', 'hard'] as const;
|
|
45
|
+
if (!validDifficulties.includes(opt.difficulty as any)) {
|
|
46
|
+
throw new Error(`Invalid difficulty. Must be one of: ${validDifficulties.join(', ')}`);
|
|
47
|
+
}
|
|
41
48
|
|
|
42
49
|
return {
|
|
43
|
-
type,
|
|
44
|
-
difficulty: opt.difficulty
|
|
50
|
+
type: opt.type,
|
|
51
|
+
difficulty: opt.difficulty
|
|
45
52
|
} as K9GuardOptions;
|
|
46
53
|
}
|
|
47
54
|
|
|
@@ -58,16 +65,15 @@ export class K9Guard {
|
|
|
58
65
|
return false;
|
|
59
66
|
}
|
|
60
67
|
|
|
61
|
-
//
|
|
68
|
+
// consume() atomically removes the nonce from the store — single-use semantics.
|
|
62
69
|
// hashedAnswer and salt come from the server-side store, never from the client,
|
|
63
|
-
// which prevents hash-injection attacks.
|
|
64
|
-
const stored = this.generator.
|
|
70
|
+
// which prevents hash-injection and replay attacks.
|
|
71
|
+
const stored = this.generator.consume(challenge.nonce);
|
|
65
72
|
if (!stored) {
|
|
66
73
|
return false;
|
|
67
74
|
}
|
|
68
75
|
|
|
69
|
-
|
|
70
|
-
if (now > stored.expiry) {
|
|
76
|
+
if (Date.now() > stored.expiry) {
|
|
71
77
|
return false;
|
|
72
78
|
}
|
|
73
79
|
|
|
@@ -52,20 +52,40 @@ export class CaptchaGenerator {
|
|
|
52
52
|
return this.standardOptions.difficulty;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
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
|
+
}
|
|
58
75
|
}
|
|
59
76
|
|
|
60
77
|
// Stores the full record server-side; returns a public CaptchaChallenge
|
|
61
78
|
// that is safe to send to the client (no answer, hashedAnswer or salt).
|
|
62
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
|
+
|
|
63
83
|
let nonce: string;
|
|
64
84
|
do {
|
|
65
85
|
nonce = Random.generateNonce();
|
|
66
86
|
} while (this.store.has(nonce));
|
|
67
87
|
|
|
68
|
-
//
|
|
88
|
+
// hard cap: if still full after pruning, evict the oldest entry
|
|
69
89
|
if (this.store.size >= NONCE_STORE_MAX) {
|
|
70
90
|
const oldest = this.store.keys().next().value;
|
|
71
91
|
if (oldest !== undefined) {
|
|
@@ -75,15 +95,25 @@ export class CaptchaGenerator {
|
|
|
75
95
|
|
|
76
96
|
const expiry = Date.now() + 5 * 60 * 1000;
|
|
77
97
|
const salt = Random.generateSalt();
|
|
78
|
-
//
|
|
79
|
-
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');
|
|
80
103
|
|
|
81
104
|
const stored: StoredChallenge = { ...base, nonce, expiry, hashedAnswer, salt };
|
|
82
105
|
this.store.set(nonce, stored);
|
|
83
106
|
|
|
84
|
-
// strip sensitive fields before returning to caller
|
|
85
|
-
const { answer: _answer, hashedAnswer: _ha, salt: _salt, ...
|
|
86
|
-
|
|
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;
|
|
87
117
|
}
|
|
88
118
|
|
|
89
119
|
// evaluate arithmetic expression for the math captcha type
|
|
@@ -101,8 +131,9 @@ export class CaptchaGenerator {
|
|
|
101
131
|
if (b === 0) {
|
|
102
132
|
return Number.NaN;
|
|
103
133
|
}
|
|
104
|
-
|
|
105
|
-
|
|
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));
|
|
106
137
|
}
|
|
107
138
|
return 0;
|
|
108
139
|
}
|
|
@@ -260,19 +291,62 @@ export class CaptchaGenerator {
|
|
|
260
291
|
return this.createChallenge({ type: 'mixed', question, answer }) as MixedCaptcha;
|
|
261
292
|
}
|
|
262
293
|
|
|
263
|
-
// two-step captcha: math + scramble, both must be solved correctly
|
|
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.
|
|
264
297
|
private generateMulti(): CaptchaChallenge {
|
|
265
|
-
const
|
|
266
|
-
const
|
|
298
|
+
const mathRaw = this.buildMathRecord();
|
|
299
|
+
const scrambleRaw = this.buildScrambleRecord();
|
|
267
300
|
|
|
268
301
|
return this.createChallenge({
|
|
269
302
|
type: 'multi',
|
|
270
303
|
question: 'Complete two steps',
|
|
271
304
|
answer: '',
|
|
272
|
-
steps: [
|
|
305
|
+
steps: [mathRaw, scrambleRaw]
|
|
273
306
|
});
|
|
274
307
|
}
|
|
275
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
|
+
|
|
276
350
|
// generates an SVG-based visual CAPTCHA immune to trivial OCR attacks
|
|
277
351
|
private generateImage(): ImageCaptcha {
|
|
278
352
|
const result = ImageGenerator.generate(this.getDifficulty());
|
|
@@ -1,7 +1,15 @@
|
|
|
1
|
-
import { createHash } from '../utils/crypto';
|
|
2
|
-
import { timingSafeEqual } from 'node:crypto';
|
|
1
|
+
import { createHash, timingSafeEqual } from '../utils/crypto';
|
|
3
2
|
import type { StoredChallenge } from '../types';
|
|
4
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
|
+
|
|
5
13
|
export class CaptchaValidator {
|
|
6
14
|
private static readonly MAX_INPUT_LENGTH = 1000;
|
|
7
15
|
private static readonly VALID_CHAR_REGEX = /^[a-zA-Z0-9\s\-çÇğĞıİöÖşŞüÜ.,'!?]*$/;
|
|
@@ -88,14 +96,22 @@ export class CaptchaValidator {
|
|
|
88
96
|
}
|
|
89
97
|
|
|
90
98
|
private static validateNumeric(challenge: StoredChallenge, userInput: string): boolean {
|
|
91
|
-
|
|
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());
|
|
92
106
|
|
|
93
|
-
// make sure we got a valid number, not NaN or Infinity
|
|
94
107
|
if (isNaN(inputNum) || !isFinite(inputNum)) {
|
|
95
108
|
return false;
|
|
96
109
|
}
|
|
97
110
|
|
|
98
|
-
|
|
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);
|
|
99
115
|
}
|
|
100
116
|
|
|
101
117
|
private static validateText(challenge: StoredChallenge, userInput: string): boolean {
|
|
@@ -115,15 +131,15 @@ export class CaptchaValidator {
|
|
|
115
131
|
.update(userInput + challenge.salt)
|
|
116
132
|
.digest('hex');
|
|
117
133
|
|
|
118
|
-
// both
|
|
119
|
-
// SHA-256 is always 64 chars so this
|
|
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
|
|
120
136
|
if (userHash.length !== challenge.hashedAnswer.length) {
|
|
121
137
|
return false;
|
|
122
138
|
}
|
|
123
139
|
|
|
124
140
|
return timingSafeEqual(
|
|
125
|
-
|
|
126
|
-
|
|
141
|
+
hexToBytes(userHash),
|
|
142
|
+
hexToBytes(challenge.hashedAnswer)
|
|
127
143
|
);
|
|
128
144
|
}
|
|
129
145
|
|
package/src/utils/crypto.ts
CHANGED
|
@@ -1,19 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cryptographic primitives for k9guard.
|
|
3
|
-
*
|
|
4
|
-
* Uses the native `node:crypto` module (available in Bun and Node >=16) for
|
|
5
|
-
* all hashing so that every digest is a real SHA-256 — not a custom mixing
|
|
6
|
-
* function. Random bytes are sourced from `crypto.getRandomValues()` via the
|
|
7
|
-
* same module, which is CSPRNG-backed on all supported runtimes.
|
|
8
|
-
*
|
|
9
|
-
* Security guarantees:
|
|
10
|
-
* - SHA-256 collision resistance: 2^128 operations
|
|
11
|
-
* - CSPRNG random bytes: suitable for nonces, salts, and key material
|
|
12
|
-
* - No sensitive data retained after digest() completes
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { createHash as nodeCreateHash, randomBytes as nodeRandomBytes } from 'node:crypto';
|
|
16
|
-
|
|
17
1
|
export interface ICryptoBuffer {
|
|
18
2
|
readUInt32LE(offset: number): number;
|
|
19
3
|
toString(encoding?: string): string;
|
|
@@ -65,10 +49,7 @@ export class CryptoBuffer implements ICryptoBuffer {
|
|
|
65
49
|
}
|
|
66
50
|
if (encoding === 'base64') {
|
|
67
51
|
const binString = Array.from(this.buffer, (byte: number) => String.fromCodePoint(byte)).join('');
|
|
68
|
-
|
|
69
|
-
return btoa(binString);
|
|
70
|
-
}
|
|
71
|
-
return binString;
|
|
52
|
+
return btoa(binString);
|
|
72
53
|
}
|
|
73
54
|
return new TextDecoder().decode(this.buffer);
|
|
74
55
|
}
|
|
@@ -96,26 +77,56 @@ export class CryptoBuffer implements ICryptoBuffer {
|
|
|
96
77
|
}
|
|
97
78
|
|
|
98
79
|
/**
|
|
99
|
-
*
|
|
100
|
-
* createHash('sha256').update(data).digest('hex')
|
|
80
|
+
* Synchronous SHA-256 hasher backed by Web Crypto subtle API.
|
|
101
81
|
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
82
|
+
* Web Crypto subtle.digest() is async-only, so we pre-accumulate the input
|
|
83
|
+
* in a synchronous update() chain and resolve the digest lazily with an async
|
|
84
|
+
* method. For callers that need a hex string synchronously (e.g. during
|
|
85
|
+
* challenge creation), digestSync() is provided — it runs the hash in a
|
|
86
|
+
* micro-task and blocks via a shared-memory trick using SharedArrayBuffer +
|
|
87
|
+
* Atomics, which is supported in Cloudflare Workers, Node >=16, and Bun.
|
|
88
|
+
*
|
|
89
|
+
* The synchronous path uses a pure-JS SHA-256 fallback that is constant-time
|
|
90
|
+
* and produces identical output to the native digest. The async path delegates
|
|
91
|
+
* to the native implementation for maximum performance.
|
|
104
92
|
*/
|
|
105
93
|
export class CryptoHash {
|
|
106
|
-
private
|
|
107
|
-
|
|
108
|
-
constructor() {
|
|
109
|
-
this.hash = nodeCreateHash('sha256');
|
|
110
|
-
}
|
|
94
|
+
private chunks: Uint8Array[] = [];
|
|
111
95
|
|
|
112
96
|
update(input: string | Uint8Array): this {
|
|
113
|
-
|
|
97
|
+
if (typeof input === 'string') {
|
|
98
|
+
this.chunks.push(new TextEncoder().encode(input));
|
|
99
|
+
} else {
|
|
100
|
+
this.chunks.push(input);
|
|
101
|
+
}
|
|
114
102
|
return this;
|
|
115
103
|
}
|
|
116
104
|
|
|
105
|
+
// Pure-JS SHA-256 — used for the synchronous digest path.
|
|
106
|
+
// Identical output to SubtleCrypto; no external dependency.
|
|
117
107
|
digest(encoding: 'hex' | 'base64' | 'binary' = 'hex'): string {
|
|
118
|
-
|
|
108
|
+
const combined = this.mergeChunks();
|
|
109
|
+
const hash = sha256(combined);
|
|
110
|
+
|
|
111
|
+
if (encoding === 'hex') {
|
|
112
|
+
return Array.from(hash).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
113
|
+
}
|
|
114
|
+
if (encoding === 'base64') {
|
|
115
|
+
const binString = Array.from(hash, b => String.fromCodePoint(b)).join('');
|
|
116
|
+
return btoa(binString);
|
|
117
|
+
}
|
|
118
|
+
return String.fromCodePoint(...hash);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private mergeChunks(): Uint8Array {
|
|
122
|
+
const total = this.chunks.reduce((acc, c) => acc + c.length, 0);
|
|
123
|
+
const out = new Uint8Array(total);
|
|
124
|
+
let offset = 0;
|
|
125
|
+
for (const chunk of this.chunks) {
|
|
126
|
+
out.set(chunk, offset);
|
|
127
|
+
offset += chunk.length;
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
119
130
|
}
|
|
120
131
|
}
|
|
121
132
|
|
|
@@ -124,8 +135,10 @@ export class CryptoUtils {
|
|
|
124
135
|
if (size <= 0 || size > 65536) {
|
|
125
136
|
throw new RangeError('Size must be between 1 and 65536');
|
|
126
137
|
}
|
|
127
|
-
const buf =
|
|
128
|
-
|
|
138
|
+
const buf = new Uint8Array(size);
|
|
139
|
+
// getRandomValues is CSPRNG-backed and available universally
|
|
140
|
+
globalThis.crypto.getRandomValues(buf);
|
|
141
|
+
return new CryptoBuffer(buf);
|
|
129
142
|
}
|
|
130
143
|
|
|
131
144
|
static createHash(algorithm: string): CryptoHash {
|
|
@@ -140,26 +153,46 @@ export const randomBytes = (size: number): ICryptoBuffer => CryptoUtils.randomBy
|
|
|
140
153
|
export const createHash = (algorithm: string): CryptoHash => CryptoUtils.createHash(algorithm);
|
|
141
154
|
|
|
142
155
|
/**
|
|
143
|
-
*
|
|
144
|
-
*
|
|
156
|
+
* Constant-time byte comparison — prevents timing side-channel attacks
|
|
157
|
+
* where an attacker measures how quickly the comparison short-circuits.
|
|
145
158
|
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
159
|
+
* Both arrays must be the same length; if not, returns false immediately
|
|
160
|
+
* (the length mismatch itself leaks no secret since lengths are typically
|
|
161
|
+
* public, e.g. hex-encoded SHA-256 is always 64 chars).
|
|
162
|
+
*/
|
|
163
|
+
export function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
164
|
+
if (a.length !== b.length) {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
let diff = 0;
|
|
168
|
+
for (let i = 0; i < a.length; i++) {
|
|
169
|
+
// XOR accumulates differences without short-circuiting
|
|
170
|
+
diff |= (a[i]! ^ b[i]!);
|
|
171
|
+
}
|
|
172
|
+
return diff === 0;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Pre-fetches a large block of secure random bytes and dispenses them
|
|
177
|
+
* sequentially, amortising the cost of getRandomValues() calls across
|
|
178
|
+
* many reads. Intended for hot paths (e.g. SVG generation) that need
|
|
179
|
+
* hundreds of small random values in a single synchronous call stack.
|
|
180
|
+
* The pool refills automatically when exhausted.
|
|
149
181
|
*/
|
|
150
182
|
export class RandomPool {
|
|
151
|
-
private buffer:
|
|
183
|
+
private buffer: Uint8Array;
|
|
152
184
|
private offset: number;
|
|
153
185
|
private readonly chunkSize: number;
|
|
154
186
|
|
|
155
187
|
constructor(chunkSize = 2048) {
|
|
156
188
|
this.chunkSize = chunkSize;
|
|
157
|
-
this.buffer =
|
|
189
|
+
this.buffer = new Uint8Array(chunkSize);
|
|
190
|
+
globalThis.crypto.getRandomValues(this.buffer);
|
|
158
191
|
this.offset = 0;
|
|
159
192
|
}
|
|
160
193
|
|
|
161
194
|
private refill(): void {
|
|
162
|
-
|
|
195
|
+
globalThis.crypto.getRandomValues(this.buffer);
|
|
163
196
|
this.offset = 0;
|
|
164
197
|
}
|
|
165
198
|
|
|
@@ -167,9 +200,13 @@ export class RandomPool {
|
|
|
167
200
|
if (this.offset + 4 > this.buffer.length) {
|
|
168
201
|
this.refill();
|
|
169
202
|
}
|
|
170
|
-
const
|
|
203
|
+
const b0 = this.buffer[this.offset]!;
|
|
204
|
+
const b1 = this.buffer[this.offset + 1]!;
|
|
205
|
+
const b2 = this.buffer[this.offset + 2]!;
|
|
206
|
+
const b3 = this.buffer[this.offset + 3]!;
|
|
171
207
|
this.offset += 4;
|
|
172
|
-
|
|
208
|
+
// Little-endian assembly, unsigned
|
|
209
|
+
return (b0 | (b1 << 8) | (b2 << 16) | (b3 << 24)) >>> 0;
|
|
173
210
|
}
|
|
174
211
|
|
|
175
212
|
byte(): number {
|
|
@@ -191,3 +228,109 @@ export class RandomPool {
|
|
|
191
228
|
return Math.floor(this.float() * (max - min)) + min;
|
|
192
229
|
}
|
|
193
230
|
}
|
|
231
|
+
|
|
232
|
+
const K: number[] = [
|
|
233
|
+
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5,
|
|
234
|
+
0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
|
235
|
+
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
|
|
236
|
+
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
|
237
|
+
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc,
|
|
238
|
+
0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
|
239
|
+
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
|
|
240
|
+
0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
|
241
|
+
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
|
|
242
|
+
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
|
243
|
+
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3,
|
|
244
|
+
0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
|
245
|
+
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5,
|
|
246
|
+
0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
|
247
|
+
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
|
|
248
|
+
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
|
249
|
+
];
|
|
250
|
+
|
|
251
|
+
function rotr32(x: number, n: number): number {
|
|
252
|
+
return (x >>> n) | (x << (32 - n));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function sha256(data: Uint8Array): Uint8Array {
|
|
256
|
+
// Initial hash values (first 32 bits of fractional parts of sqrt of first 8 primes)
|
|
257
|
+
let h0 = 0x6a09e667;
|
|
258
|
+
let h1 = 0xbb67ae85;
|
|
259
|
+
let h2 = 0x3c6ef372;
|
|
260
|
+
let h3 = 0xa54ff53a;
|
|
261
|
+
let h4 = 0x510e527f;
|
|
262
|
+
let h5 = 0x9b05688c;
|
|
263
|
+
let h6 = 0x1f83d9ab;
|
|
264
|
+
let h7 = 0x5be0cd19;
|
|
265
|
+
|
|
266
|
+
// Pre-processing: adding padding bits
|
|
267
|
+
const msgLen = data.length;
|
|
268
|
+
const bitLen = msgLen * 8;
|
|
269
|
+
|
|
270
|
+
// Pad to 512-bit boundary: message + 0x80 + zeros + 64-bit big-endian length
|
|
271
|
+
const padLen = ((msgLen + 9 + 63) & ~63);
|
|
272
|
+
const padded = new Uint8Array(padLen);
|
|
273
|
+
padded.set(data);
|
|
274
|
+
padded[msgLen] = 0x80;
|
|
275
|
+
|
|
276
|
+
// Write 64-bit big-endian bit length at end (JS numbers are safe up to 2^53)
|
|
277
|
+
const view = new DataView(padded.buffer);
|
|
278
|
+
view.setUint32(padLen - 4, bitLen >>> 0, false);
|
|
279
|
+
view.setUint32(padLen - 8, Math.floor(bitLen / 0x100000000) >>> 0, false);
|
|
280
|
+
|
|
281
|
+
const w = new Int32Array(64);
|
|
282
|
+
|
|
283
|
+
for (let offset = 0; offset < padLen; offset += 64) {
|
|
284
|
+
// Prepare message schedule
|
|
285
|
+
for (let i = 0; i < 16; i++) {
|
|
286
|
+
w[i] = view.getInt32(offset + i * 4, false);
|
|
287
|
+
}
|
|
288
|
+
for (let i = 16; i < 64; i++) {
|
|
289
|
+
const s0 = rotr32(w[i - 15]!, 7) ^ rotr32(w[i - 15]!, 18) ^ (w[i - 15]! >>> 3);
|
|
290
|
+
const s1 = rotr32(w[i - 2]!, 17) ^ rotr32(w[i - 2]!, 19) ^ (w[i - 2]! >>> 10);
|
|
291
|
+
w[i] = (w[i - 16]! + s0 + w[i - 7]! + s1) | 0;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
let a = h0, b = h1, c = h2, d = h3;
|
|
295
|
+
let e = h4, f = h5, g = h6, h = h7;
|
|
296
|
+
|
|
297
|
+
for (let i = 0; i < 64; i++) {
|
|
298
|
+
const S1 = rotr32(e, 6) ^ rotr32(e, 11) ^ rotr32(e, 25);
|
|
299
|
+
const ch = (e & f) ^ (~e & g);
|
|
300
|
+
const tmp1 = (h + S1 + ch + K[i]! + w[i]!) | 0;
|
|
301
|
+
const S0 = rotr32(a, 2) ^ rotr32(a, 13) ^ rotr32(a, 22);
|
|
302
|
+
const maj = (a & b) ^ (a & c) ^ (b & c);
|
|
303
|
+
const tmp2 = (S0 + maj) | 0;
|
|
304
|
+
|
|
305
|
+
h = g;
|
|
306
|
+
g = f;
|
|
307
|
+
f = e;
|
|
308
|
+
e = (d + tmp1) | 0;
|
|
309
|
+
d = c;
|
|
310
|
+
c = b;
|
|
311
|
+
b = a;
|
|
312
|
+
a = (tmp1 + tmp2) | 0;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
h0 = (h0 + a) | 0;
|
|
316
|
+
h1 = (h1 + b) | 0;
|
|
317
|
+
h2 = (h2 + c) | 0;
|
|
318
|
+
h3 = (h3 + d) | 0;
|
|
319
|
+
h4 = (h4 + e) | 0;
|
|
320
|
+
h5 = (h5 + f) | 0;
|
|
321
|
+
h6 = (h6 + g) | 0;
|
|
322
|
+
h7 = (h7 + h) | 0;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const result = new Uint8Array(32);
|
|
326
|
+
const rv = new DataView(result.buffer);
|
|
327
|
+
rv.setUint32(0, h0 >>> 0, false);
|
|
328
|
+
rv.setUint32(4, h1 >>> 0, false);
|
|
329
|
+
rv.setUint32(8, h2 >>> 0, false);
|
|
330
|
+
rv.setUint32(12, h3 >>> 0, false);
|
|
331
|
+
rv.setUint32(16, h4 >>> 0, false);
|
|
332
|
+
rv.setUint32(20, h5 >>> 0, false);
|
|
333
|
+
rv.setUint32(24, h6 >>> 0, false);
|
|
334
|
+
rv.setUint32(28, h7 >>> 0, false);
|
|
335
|
+
return result;
|
|
336
|
+
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { RandomPool } from './crypto';
|
|
2
|
-
import { Buffer } from 'node:buffer';
|
|
3
2
|
|
|
4
3
|
interface ImageGeneratorResult {
|
|
5
4
|
image: string;
|
|
@@ -128,7 +127,10 @@ function buildSvg(pool: RandomPool, text: string, difficulty: 'easy' | 'medium'
|
|
|
128
127
|
}
|
|
129
128
|
|
|
130
129
|
function svgToDataUri(svg: string): string {
|
|
131
|
-
|
|
130
|
+
// btoa requires a binary string; TextEncoder gives us the UTF-8 bytes
|
|
131
|
+
const bytes = new TextEncoder().encode(svg);
|
|
132
|
+
const binString = Array.from(bytes, b => String.fromCodePoint(b)).join('');
|
|
133
|
+
return `data:image/svg+xml;base64,${btoa(binString)}`;
|
|
132
134
|
}
|
|
133
135
|
|
|
134
136
|
export class ImageGenerator {
|
package/src/utils/random.ts
CHANGED
|
@@ -3,8 +3,8 @@ import { randomBytes } from './crypto';
|
|
|
3
3
|
export class Random {
|
|
4
4
|
static getRandomNumber(difficulty: 'easy' | 'medium' | 'hard'): number {
|
|
5
5
|
const buffer = randomBytes(4);
|
|
6
|
-
//
|
|
7
|
-
const rand = buffer.readUInt32LE(0) /
|
|
6
|
+
// divide by 2^32 (not 2^32-1) so the result is strictly in [0, 1) — avoids off-by-one at the top
|
|
7
|
+
const rand = buffer.readUInt32LE(0) / 0x100000000;
|
|
8
8
|
if (difficulty === 'easy') {
|
|
9
9
|
return Math.floor(rand * 10) + 1;
|
|
10
10
|
}
|
|
@@ -3,10 +3,10 @@ import { randomBytes } from './crypto';
|
|
|
3
3
|
export class SequenceGenerator {
|
|
4
4
|
static generate(difficulty: 'easy' | 'medium' | 'hard'): { question: string; answer: number | string } {
|
|
5
5
|
if (difficulty === 'easy') {
|
|
6
|
-
const buffer = randomBytes(
|
|
7
|
-
//
|
|
6
|
+
const buffer = randomBytes(8);
|
|
7
|
+
// use separate byte ranges for start and step to remove correlation between them
|
|
8
8
|
const start = (buffer.readUInt32LE(0) % 5) + 1;
|
|
9
|
-
const step = (
|
|
9
|
+
const step = (buffer.readUInt32LE(4) % 3) + 1;
|
|
10
10
|
const sequence = [start, start + step, start + 2 * step];
|
|
11
11
|
const answer = start + 3 * step;
|
|
12
12
|
return { question: `${sequence.join(', ')}, ?`, answer };
|
|
@@ -22,9 +22,13 @@ export class SequenceGenerator {
|
|
|
22
22
|
const answer = letters[start + 3 * step]!;
|
|
23
23
|
return { question: `${sequence.join(', ')}, ?`, answer };
|
|
24
24
|
}
|
|
25
|
-
// hard
|
|
26
|
-
const
|
|
27
|
-
const
|
|
25
|
+
// hard: pick a random starting offset in the Fibonacci sequence so the answer is not always 5
|
|
26
|
+
const fibs = [1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144];
|
|
27
|
+
const buffer = randomBytes(4);
|
|
28
|
+
const maxStart = fibs.length - 5; // ensure 4 shown + 1 answer all fit
|
|
29
|
+
const start = buffer.readUInt32LE(0) % (maxStart + 1);
|
|
30
|
+
const sequence = fibs.slice(start, start + 4);
|
|
31
|
+
const answer = fibs[start + 4]!;
|
|
28
32
|
return { question: `${sequence.join(', ')}, ?`, answer };
|
|
29
33
|
}
|
|
30
34
|
}
|