@kcaptcha/core 0.1.0
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/dist/challenge.d.ts +20 -0
- package/dist/challenge.js +37 -0
- package/dist/challenge.js.map +1 -0
- package/dist/config.d.ts +51 -0
- package/dist/config.js +61 -0
- package/dist/config.js.map +1 -0
- package/dist/font.d.ts +4 -0
- package/dist/font.js +129 -0
- package/dist/font.js.map +1 -0
- package/dist/geometry.d.ts +15 -0
- package/dist/geometry.js +109 -0
- package/dist/geometry.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/rate-limit.d.ts +14 -0
- package/dist/rate-limit.js +24 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/stores/memory.d.ts +22 -0
- package/dist/stores/memory.js +65 -0
- package/dist/stores/memory.js.map +1 -0
- package/dist/stores/redis.d.ts +27 -0
- package/dist/stores/redis.js +44 -0
- package/dist/stores/redis.js.map +1 -0
- package/dist/token.d.ts +31 -0
- package/dist/token.js +68 -0
- package/dist/token.js.map +1 -0
- package/dist/verify.d.ts +27 -0
- package/dist/verify.js +53 -0
- package/dist/verify.js.map +1 -0
- package/package.json +23 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ResolvedConfig } from './config.js';
|
|
2
|
+
import { type Geometry } from './geometry.js';
|
|
3
|
+
/** Everything the browser receives. The code itself is NOT in here. */
|
|
4
|
+
export interface Challenge {
|
|
5
|
+
/** Signed, opaque token. Send it back with the answer. */
|
|
6
|
+
token: string;
|
|
7
|
+
/** Dot geometry for the animation. */
|
|
8
|
+
geometry: Geometry;
|
|
9
|
+
/** Seconds until this challenge expires. */
|
|
10
|
+
expiresIn: number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Internal: also returns the plaintext code so tests can solve challenges.
|
|
14
|
+
* Deliberately NOT re-exported from index.ts.
|
|
15
|
+
*/
|
|
16
|
+
export declare function _createChallengeWithCode(config: ResolvedConfig): {
|
|
17
|
+
code: string;
|
|
18
|
+
challenge: Challenge;
|
|
19
|
+
};
|
|
20
|
+
export declare function createChallenge(config: ResolvedConfig): Challenge;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { randomInt } from 'node:crypto';
|
|
2
|
+
import { buildGeometry } from './geometry.js';
|
|
3
|
+
import { answerMac, nowSeconds, randomId, signToken } from './token.js';
|
|
4
|
+
function randomCode(length, charset) {
|
|
5
|
+
let out = '';
|
|
6
|
+
for (let i = 0; i < length; i++) {
|
|
7
|
+
out += charset[randomInt(0, charset.length)];
|
|
8
|
+
}
|
|
9
|
+
return out;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Internal: also returns the plaintext code so tests can solve challenges.
|
|
13
|
+
* Deliberately NOT re-exported from index.ts.
|
|
14
|
+
*/
|
|
15
|
+
export function _createChallengeWithCode(config) {
|
|
16
|
+
const code = randomCode(config.codeLength, config.charset);
|
|
17
|
+
const id = randomId();
|
|
18
|
+
const exp = nowSeconds() + config.expire;
|
|
19
|
+
const token = signToken(config.secret, 'challenge', {
|
|
20
|
+
t: 'challenge',
|
|
21
|
+
id,
|
|
22
|
+
exp,
|
|
23
|
+
mac: answerMac(config.secret, id, code),
|
|
24
|
+
});
|
|
25
|
+
return {
|
|
26
|
+
code,
|
|
27
|
+
challenge: {
|
|
28
|
+
token,
|
|
29
|
+
geometry: buildGeometry(code),
|
|
30
|
+
expiresIn: config.expire,
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function createChallenge(config) {
|
|
35
|
+
return _createChallengeWithCode(config).challenge;
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=challenge.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"challenge.js","sourceRoot":"","sources":["../src/challenge.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAExC,OAAO,EAAE,aAAa,EAAiB,MAAM,eAAe,CAAC;AAC7D,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAYxE,SAAS,UAAU,CAAC,MAAc,EAAE,OAAe;IACjD,IAAI,GAAG,GAAG,EAAE,CAAC;IACb,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QAChC,GAAG,IAAI,OAAO,CAAC,SAAS,CAAC,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC;IAC/C,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,wBAAwB,CAAC,MAAsB;IAI7D,MAAM,IAAI,GAAG,UAAU,CAAC,MAAM,CAAC,UAAU,EAAE,MAAM,CAAC,OAAO,CAAC,CAAC;IAC3D,MAAM,EAAE,GAAG,QAAQ,EAAE,CAAC;IACtB,MAAM,GAAG,GAAG,UAAU,EAAE,GAAG,MAAM,CAAC,MAAM,CAAC;IAEzC,MAAM,KAAK,GAAG,SAAS,CAAC,MAAM,CAAC,MAAM,EAAE,WAAW,EAAE;QAClD,CAAC,EAAE,WAAW;QACd,EAAE;QACF,GAAG;QACH,GAAG,EAAE,SAAS,CAAC,MAAM,CAAC,MAAM,EAAE,EAAE,EAAE,IAAI,CAAC;KACxC,CAAC,CAAC;IAEH,OAAO;QACL,IAAI;QACJ,SAAS,EAAE;YACT,KAAK;YACL,QAAQ,EAAE,aAAa,CAAC,IAAI,CAAC;YAC7B,SAAS,EAAE,MAAM,CAAC,MAAM;SACzB;KACF,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,eAAe,CAAC,MAAsB;IACpD,OAAO,wBAAwB,CAAC,MAAM,CAAC,CAAC,SAAS,CAAC;AACpD,CAAC"}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export interface KCaptchaStore {
|
|
2
|
+
/** Increment the counter at `key` (creating it with a TTL if new). Returns the new count. */
|
|
3
|
+
incr(key: string, ttlSeconds: number): Promise<number>;
|
|
4
|
+
/** Mark `key` as used. Returns true if newly set, false if it was already used. */
|
|
5
|
+
setOnce(key: string, ttlSeconds: number): Promise<boolean>;
|
|
6
|
+
}
|
|
7
|
+
export interface RateLimitOptions {
|
|
8
|
+
/** Max requests allowed per client IP per window. */
|
|
9
|
+
max: number;
|
|
10
|
+
/** Window length in seconds. */
|
|
11
|
+
windowSeconds: number;
|
|
12
|
+
}
|
|
13
|
+
export interface KCaptchaOptions {
|
|
14
|
+
/** REQUIRED. Server-side signing secret, at least 32 characters. Never expose to the client. */
|
|
15
|
+
secret: string;
|
|
16
|
+
/** Seconds until a challenge expires. Default 120. */
|
|
17
|
+
expire?: number;
|
|
18
|
+
/** Seconds a pass token (issued after solving) stays valid. Default 300. */
|
|
19
|
+
passExpire?: number;
|
|
20
|
+
/** Per-IP rate limit for new-code / verify-captcha. Default 10 requests per 60 s. */
|
|
21
|
+
rateLimit?: Partial<RateLimitOptions>;
|
|
22
|
+
/** Characters in each code. Default 5. */
|
|
23
|
+
codeLength?: number;
|
|
24
|
+
/** Characters codes are drawn from. Must all exist in the font. */
|
|
25
|
+
charset?: string;
|
|
26
|
+
/** Where replay-protection and rate-limit counters live. Default: in-memory. */
|
|
27
|
+
store?: KCaptchaStore;
|
|
28
|
+
}
|
|
29
|
+
export interface ResolvedConfig {
|
|
30
|
+
readonly secret: string;
|
|
31
|
+
readonly expire: number;
|
|
32
|
+
readonly passExpire: number;
|
|
33
|
+
readonly rateLimit: Readonly<RateLimitOptions>;
|
|
34
|
+
readonly codeLength: number;
|
|
35
|
+
readonly charset: string;
|
|
36
|
+
readonly store: KCaptchaStore;
|
|
37
|
+
}
|
|
38
|
+
export declare const DEFAULTS: {
|
|
39
|
+
readonly expire: 120;
|
|
40
|
+
readonly passExpire: 300;
|
|
41
|
+
readonly rateLimit: {
|
|
42
|
+
readonly max: 10;
|
|
43
|
+
readonly windowSeconds: 60;
|
|
44
|
+
};
|
|
45
|
+
readonly codeLength: 5;
|
|
46
|
+
readonly charset: "ABCDEFGHIJKLMNPQRSTUVWXYZ";
|
|
47
|
+
};
|
|
48
|
+
export declare class KCaptchaConfigError extends Error {
|
|
49
|
+
constructor(message: string);
|
|
50
|
+
}
|
|
51
|
+
export declare function resolveConfig(options: KCaptchaOptions): ResolvedConfig;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { FONT } from './font.js';
|
|
2
|
+
import { MemoryStore } from './stores/memory.js';
|
|
3
|
+
export const DEFAULTS = {
|
|
4
|
+
expire: 120,
|
|
5
|
+
passExpire: 300,
|
|
6
|
+
rateLimit: { max: 10, windowSeconds: 60 },
|
|
7
|
+
codeLength: 5,
|
|
8
|
+
// O and 0 are drawn almost identically by the stroke font, so they are
|
|
9
|
+
// left out by default to keep the CAPTCHA humanly solvable.
|
|
10
|
+
charset: 'ABCDEFGHIJKLMNPQRSTUVWXYZ',
|
|
11
|
+
};
|
|
12
|
+
export class KCaptchaConfigError extends Error {
|
|
13
|
+
constructor(message) {
|
|
14
|
+
super(`[kCAPTCHA] ${message}`);
|
|
15
|
+
this.name = 'KCaptchaConfigError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function intOption(name, value, fallback, min, max) {
|
|
19
|
+
if (value === undefined)
|
|
20
|
+
return fallback;
|
|
21
|
+
if (!Number.isInteger(value) || value < min || value > max) {
|
|
22
|
+
throw new KCaptchaConfigError(`"${name}" must be an integer between ${min} and ${max} (got ${String(value)}).`);
|
|
23
|
+
}
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
export function resolveConfig(options) {
|
|
27
|
+
if (!options || typeof options !== 'object') {
|
|
28
|
+
throw new KCaptchaConfigError('An options object with a "secret" is required.');
|
|
29
|
+
}
|
|
30
|
+
if (typeof options.secret !== 'string' || options.secret.length < 32) {
|
|
31
|
+
throw new KCaptchaConfigError('"secret" is required and must be at least 32 characters. ' +
|
|
32
|
+
'Generate one with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"');
|
|
33
|
+
}
|
|
34
|
+
const expire = intOption('expire', options.expire, DEFAULTS.expire, 10, 900);
|
|
35
|
+
const passExpire = intOption('passExpire', options.passExpire, DEFAULTS.passExpire, 10, 3600);
|
|
36
|
+
const codeLength = intOption('codeLength', options.codeLength, DEFAULTS.codeLength, 4, 8);
|
|
37
|
+
const rateLimit = {
|
|
38
|
+
max: intOption('rateLimit.max', options.rateLimit?.max, DEFAULTS.rateLimit.max, 1, 10_000),
|
|
39
|
+
windowSeconds: intOption('rateLimit.windowSeconds', options.rateLimit?.windowSeconds, DEFAULTS.rateLimit.windowSeconds, 1, 86_400),
|
|
40
|
+
};
|
|
41
|
+
// Normalise charset: uppercase, de-duplicated, every char must have a glyph.
|
|
42
|
+
const charset = Array.from(new Set((options.charset ?? DEFAULTS.charset).toUpperCase())).join('');
|
|
43
|
+
if (charset.length < 16) {
|
|
44
|
+
throw new KCaptchaConfigError('"charset" needs at least 16 distinct characters for enough entropy.');
|
|
45
|
+
}
|
|
46
|
+
for (const ch of charset) {
|
|
47
|
+
if (!(ch in FONT)) {
|
|
48
|
+
throw new KCaptchaConfigError(`"charset" contains "${ch}", which has no glyph in the font.`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return Object.freeze({
|
|
52
|
+
secret: options.secret,
|
|
53
|
+
expire,
|
|
54
|
+
passExpire,
|
|
55
|
+
rateLimit: Object.freeze(rateLimit),
|
|
56
|
+
codeLength,
|
|
57
|
+
charset,
|
|
58
|
+
store: options.store ?? new MemoryStore(),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
|
package/dist/font.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export type Pt = readonly [number, number];
|
|
2
|
+
export type Segment = readonly ['L', Pt, Pt] | readonly ['Q', Pt, Pt, Pt] | readonly ['A', number, number, number, number, number, number];
|
|
3
|
+
export type Glyph = readonly Segment[];
|
|
4
|
+
export declare const FONT: Readonly<Record<string, Glyph>>;
|
package/dist/font.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Stroke font for kCAPTCHA — corrected glyphs (all joints exact).
|
|
2
|
+
// 0..1 unit box, y grows downward.
|
|
3
|
+
// Arc angles in degrees, y down: 0 = right, 90 = bottom, 180 = left, 270/-90 = top.
|
|
4
|
+
const L = (a, b) => ['L', a, b];
|
|
5
|
+
const Q = (a, c, b) => ['Q', a, c, b];
|
|
6
|
+
const A = (cx, cy, rx, ry, a0, a1) => ['A', cx, cy, rx, ry, a0, a1];
|
|
7
|
+
export const FONT = {
|
|
8
|
+
// ---------- LETTERS ----------
|
|
9
|
+
'A': [
|
|
10
|
+
L([0.06, 1.00], [0.50, 0.00]), // left leg
|
|
11
|
+
L([0.50, 0.00], [0.94, 1.00]), // right leg
|
|
12
|
+
L([0.20, 0.68], [0.80, 0.68]), // crossbar: x = 0.50 ± 0.44·0.68 exactly on both legs
|
|
13
|
+
],
|
|
14
|
+
'B': [
|
|
15
|
+
L([0.00, 0.00], [0.00, 1.00]), // stem
|
|
16
|
+
A(0.00, 0.25, 0.42, 0.25, -90, 90), // upper bowl: (0,0) → (0,0.50)
|
|
17
|
+
A(0.00, 0.75, 0.48, 0.25, -90, 90), // lower bowl: (0,0.50) → (0,1.00), wider
|
|
18
|
+
],
|
|
19
|
+
'C': [
|
|
20
|
+
A(0.50, 0.50, 0.46, 0.50, -55, -305), // circle with a symmetric 110° gap on the right
|
|
21
|
+
],
|
|
22
|
+
'D': [
|
|
23
|
+
L([0.00, 0.00], [0.00, 1.00]),
|
|
24
|
+
A(0.00, 0.50, 0.92, 0.50, -90, 90), // (0,0) → (0,1), max width at (0.92, 0.50)
|
|
25
|
+
],
|
|
26
|
+
'E': [
|
|
27
|
+
L([0.00, 0.00], [0.92, 0.00]), // top bar starts ON the stem
|
|
28
|
+
L([0.00, 0.00], [0.00, 1.00]),
|
|
29
|
+
L([0.00, 1.00], [0.92, 1.00]),
|
|
30
|
+
L([0.00, 0.50], [0.72, 0.50]),
|
|
31
|
+
],
|
|
32
|
+
'F': [
|
|
33
|
+
L([0.00, 0.00], [0.92, 0.00]), // top bar starts ON the stem
|
|
34
|
+
L([0.00, 0.00], [0.00, 1.00]),
|
|
35
|
+
L([0.00, 0.50], [0.72, 0.50]),
|
|
36
|
+
],
|
|
37
|
+
'G': [
|
|
38
|
+
A(0.50, 0.50, 0.46, 0.50, -45, -360), // arc ends exactly at 0° = (0.96, 0.50)
|
|
39
|
+
L([0.96, 0.50], [0.96, 0.76]), // spur drops straight down from the arc end
|
|
40
|
+
L([0.96, 0.76], [0.55, 0.76]), // horizontal terminal, no double-back hook
|
|
41
|
+
],
|
|
42
|
+
'H': [
|
|
43
|
+
L([0.04, 0.00], [0.04, 1.00]),
|
|
44
|
+
L([0.96, 0.00], [0.96, 1.00]),
|
|
45
|
+
L([0.04, 0.50], [0.96, 0.50]), //
|
|
46
|
+
],
|
|
47
|
+
'I': [
|
|
48
|
+
L([0.14, 0.00], [0.86, 0.00]),
|
|
49
|
+
L([0.50, 0.00], [0.50, 1.00]),
|
|
50
|
+
L([0.14, 1.00], [0.86, 1.00]),
|
|
51
|
+
],
|
|
52
|
+
'J': [
|
|
53
|
+
L([0.48, 0.00], [0.88, 0.00]),
|
|
54
|
+
L([0.88, 0.00], [0.88, 0.70]),
|
|
55
|
+
A(0.47, 0.70, 0.41, 0.30, 0, 180), // starts exactly at (0.88, 0.70); bottom lands at (0.47, 1.00)
|
|
56
|
+
],
|
|
57
|
+
'K': [
|
|
58
|
+
L([0.05, 0.00], [0.05, 1.00]), // stem
|
|
59
|
+
L([0.95, 0.00], [0.05, 0.56]), // arm ends ON the stem
|
|
60
|
+
L([0.05, 0.56], [0.95, 1.00]), // leg starts at the same vertex — no gap, no kink
|
|
61
|
+
],
|
|
62
|
+
'L': [
|
|
63
|
+
L([0.06, 0.00], [0.06, 1.00]),
|
|
64
|
+
L([0.06, 1.00], [0.92, 1.00]),
|
|
65
|
+
],
|
|
66
|
+
'M': [
|
|
67
|
+
L([0.04, 1.00], [0.04, 0.00]),
|
|
68
|
+
L([0.04, 0.00], [0.50, 0.66]),
|
|
69
|
+
L([0.50, 0.66], [0.96, 0.00]),
|
|
70
|
+
L([0.96, 0.00], [0.96, 1.00]),
|
|
71
|
+
],
|
|
72
|
+
'N': [
|
|
73
|
+
L([0.05, 1.00], [0.05, 0.00]),
|
|
74
|
+
L([0.05, 0.00], [0.95, 1.00]),
|
|
75
|
+
L([0.95, 1.00], [0.95, 0.00]),
|
|
76
|
+
],
|
|
77
|
+
'O': [A(0.50, 0.50, 0.46, 0.50, 0, 360)],
|
|
78
|
+
'P': [
|
|
79
|
+
L([0.00, 0.00], [0.00, 1.00]),
|
|
80
|
+
A(0.00, 0.27, 0.46, 0.27, -90, 90), // (0,0) → (0,0.54)
|
|
81
|
+
],
|
|
82
|
+
'Q': [
|
|
83
|
+
A(0.50, 0.48, 0.46, 0.48, 0, 360),
|
|
84
|
+
L([0.825, 0.819], [1.00, 1.00]), // tail starts exactly ON the circle (angle 45°)
|
|
85
|
+
],
|
|
86
|
+
'R': [
|
|
87
|
+
L([0.00, 0.00], [0.00, 1.00]),
|
|
88
|
+
A(0.00, 0.27, 0.46, 0.27, -90, 90),
|
|
89
|
+
L([0.296, 0.477], [0.95, 1.00]), // leg starts exactly ON the bowl (angle 50°)
|
|
90
|
+
],
|
|
91
|
+
'S': [
|
|
92
|
+
A(0.50, 0.28, 0.44, 0.22, -38, -270), // ends at waist (0.50, 0.50), tangent horizontal
|
|
93
|
+
A(0.50, 0.72, 0.44, 0.22, -90, 142), // starts at same waist, tangent horizontal — smooth
|
|
94
|
+
],
|
|
95
|
+
'T': [
|
|
96
|
+
L([0.04, 0.00], [0.96, 0.00]),
|
|
97
|
+
L([0.50, 0.00], [0.50, 1.00]),
|
|
98
|
+
],
|
|
99
|
+
'U': [
|
|
100
|
+
L([0.04, 0.00], [0.04, 0.60]),
|
|
101
|
+
A(0.50, 0.60, 0.46, 0.40, 180, 0), // (0.04, 0.60) → (0.96, 0.60), bottom at (0.50, 1.00)
|
|
102
|
+
L([0.96, 0.60], [0.96, 0.00]), // starts exactly where the arc ends
|
|
103
|
+
],
|
|
104
|
+
'V': [
|
|
105
|
+
L([0.04, 0.00], [0.50, 1.00]),
|
|
106
|
+
L([0.50, 1.00], [0.96, 0.00]),
|
|
107
|
+
],
|
|
108
|
+
'W': [
|
|
109
|
+
L([0.02, 0.00], [0.22, 1.00]),
|
|
110
|
+
L([0.22, 1.00], [0.50, 0.34]),
|
|
111
|
+
L([0.50, 0.34], [0.78, 1.00]),
|
|
112
|
+
L([0.78, 1.00], [0.98, 0.00]),
|
|
113
|
+
],
|
|
114
|
+
'X': [
|
|
115
|
+
L([0.05, 0.00], [0.95, 1.00]), // ↘ diagonal: top-left → bottom-right
|
|
116
|
+
L([0.95, 0.00], [0.05, 1.00]), // ↙ diagonal: top-right → bottom-left
|
|
117
|
+
],
|
|
118
|
+
'Y': [
|
|
119
|
+
L([0.05, 0.00], [0.50, 0.52]), // left arm ends exactly at the vertex
|
|
120
|
+
L([0.95, 0.00], [0.50, 0.52]), // right arm ends at the same vertex
|
|
121
|
+
L([0.50, 0.52], [0.50, 1.00]), // tail starts at the vertex — no gap, no kink
|
|
122
|
+
],
|
|
123
|
+
'Z': [
|
|
124
|
+
L([0.05, 0.00], [0.95, 0.00]), // top bar
|
|
125
|
+
L([0.95, 0.00], [0.05, 1.00]), // diagonal starts exactly at the top bar's end
|
|
126
|
+
L([0.05, 1.00], [0.95, 1.00]), // bottom bar starts exactly at the diagonal's end
|
|
127
|
+
],
|
|
128
|
+
};
|
|
129
|
+
//# sourceMappingURL=font.js.map
|
package/dist/font.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"font.js","sourceRoot":"","sources":["../src/font.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare const GLYPH_WIDTH = 56;
|
|
2
|
+
export declare const GLYPH_HEIGHT = 120;
|
|
3
|
+
export declare const GLYPH_GAP = 30;
|
|
4
|
+
export declare const DOT_SPACING = 7;
|
|
5
|
+
export interface Geometry {
|
|
6
|
+
/** Width of the moving string in pixels. */
|
|
7
|
+
width: number;
|
|
8
|
+
/** Height of the moving string in pixels. */
|
|
9
|
+
height: number;
|
|
10
|
+
/** Pixel distance between neighbouring dots along a pipe. */
|
|
11
|
+
spacing: number;
|
|
12
|
+
/** Each pipe is a flat polyline [x0, y0, x1, y1, ...] in string space. */
|
|
13
|
+
pipes: number[][];
|
|
14
|
+
}
|
|
15
|
+
export declare function buildGeometry(code: string): Geometry;
|
package/dist/geometry.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { randomInt } from 'node:crypto';
|
|
2
|
+
import { FONT } from './font.js';
|
|
3
|
+
// Glyph box in pixels, taken from the original Kinetic CAPTCHA demo.
|
|
4
|
+
export const GLYPH_WIDTH = 56;
|
|
5
|
+
export const GLYPH_HEIGHT = 120;
|
|
6
|
+
export const GLYPH_GAP = 30;
|
|
7
|
+
export const DOT_SPACING = 7; // smaller number = denser dots, easier to read
|
|
8
|
+
const STEP = GLYPH_WIDTH + GLYPH_GAP;
|
|
9
|
+
// Padding keeps worst-case distortion (rotation + shear + wave) inside the box.
|
|
10
|
+
const PAD_X = 16;
|
|
11
|
+
const PAD_Y = 18;
|
|
12
|
+
// Distance between sampled polyline points. Dots are placed by the client
|
|
13
|
+
// along these polylines, so this only needs to be fine enough to look smooth.
|
|
14
|
+
const SAMPLE_SPACING = 2.5;
|
|
15
|
+
// Whole-string wave warp.
|
|
16
|
+
const WARP_AMP = 1.5;
|
|
17
|
+
const WARP_FREQ = 0.045;
|
|
18
|
+
// Uniform float in [0, 1) from a CSPRNG.
|
|
19
|
+
const rnd = () => randomInt(0, 1_000_000) / 1_000_000;
|
|
20
|
+
const between = (lo, hi) => lo + rnd() * (hi - lo);
|
|
21
|
+
const round1 = (n) => Math.round(n * 10) / 10;
|
|
22
|
+
function randomTransform() {
|
|
23
|
+
return {
|
|
24
|
+
sx: between(0.95, 1.05),
|
|
25
|
+
sy: between(0.96, 1.04),
|
|
26
|
+
shear: between(-0.06, 0.06),
|
|
27
|
+
rot: between(-0.07, 0.07), // radians, about +/- 4 degrees
|
|
28
|
+
dx: between(-2, 2),
|
|
29
|
+
dy: between(-2, 2),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
// Position on a pipe in glyph-local 0..1 units.
|
|
33
|
+
function segmentPos(seg, t) {
|
|
34
|
+
if (seg[0] === 'L') {
|
|
35
|
+
const [, a, b] = seg;
|
|
36
|
+
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
|
|
37
|
+
}
|
|
38
|
+
if (seg[0] === 'Q') {
|
|
39
|
+
const [, a, c, b] = seg;
|
|
40
|
+
const mt = 1 - t;
|
|
41
|
+
return [
|
|
42
|
+
mt * mt * a[0] + 2 * mt * t * c[0] + t * t * b[0],
|
|
43
|
+
mt * mt * a[1] + 2 * mt * t * c[1] + t * t * b[1],
|
|
44
|
+
];
|
|
45
|
+
}
|
|
46
|
+
const [, cx, cy, rx, ry, a0, a1] = seg;
|
|
47
|
+
const angle = (a0 + (a1 - a0) * t) * (Math.PI / 180);
|
|
48
|
+
return [cx + rx * Math.cos(angle), cy + ry * Math.sin(angle)];
|
|
49
|
+
}
|
|
50
|
+
// Glyph-local (u, v) -> string-space pixels, with distortion applied.
|
|
51
|
+
function place(u, v, tf, glyphIndex, phase) {
|
|
52
|
+
let x = (u - 0.5) * GLYPH_WIDTH * tf.sx;
|
|
53
|
+
let y = (v - 0.5) * GLYPH_HEIGHT * tf.sy;
|
|
54
|
+
x += tf.shear * y;
|
|
55
|
+
const cos = Math.cos(tf.rot);
|
|
56
|
+
const sin = Math.sin(tf.rot);
|
|
57
|
+
const rx = x * cos - y * sin;
|
|
58
|
+
const ry = x * sin + y * cos;
|
|
59
|
+
const px = PAD_X + glyphIndex * STEP + GLYPH_WIDTH / 2 + tf.dx + rx;
|
|
60
|
+
const py = PAD_Y + GLYPH_HEIGHT / 2 + tf.dy + ry + WARP_AMP * Math.sin(px * WARP_FREQ + phase);
|
|
61
|
+
return [px, py];
|
|
62
|
+
}
|
|
63
|
+
function samplePipe(seg, tf, glyphIndex, phase) {
|
|
64
|
+
// Estimate pixel length first so point count follows real size.
|
|
65
|
+
const PROBE = 64;
|
|
66
|
+
let length = 0;
|
|
67
|
+
let prev = place(...segmentPos(seg, 0), tf, glyphIndex, phase);
|
|
68
|
+
for (let i = 1; i <= PROBE; i++) {
|
|
69
|
+
const cur = place(...segmentPos(seg, i / PROBE), tf, glyphIndex, phase);
|
|
70
|
+
length += Math.hypot(cur[0] - prev[0], cur[1] - prev[1]);
|
|
71
|
+
prev = cur;
|
|
72
|
+
}
|
|
73
|
+
const n = Math.max(8, Math.ceil(length / SAMPLE_SPACING));
|
|
74
|
+
const out = [];
|
|
75
|
+
for (let i = 0; i <= n; i++) {
|
|
76
|
+
const [x, y] = place(...segmentPos(seg, i / n), tf, glyphIndex, phase);
|
|
77
|
+
out.push(round1(x), round1(y));
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
// Fisher-Yates with a CSPRNG, so pipe order does not reveal glyph order.
|
|
82
|
+
function shuffle(items) {
|
|
83
|
+
for (let i = items.length - 1; i > 0; i--) {
|
|
84
|
+
const j = randomInt(0, i + 1);
|
|
85
|
+
[items[i], items[j]] = [items[j], items[i]];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export function buildGeometry(code) {
|
|
89
|
+
const phase = rnd() * Math.PI * 2;
|
|
90
|
+
const pipes = [];
|
|
91
|
+
for (let i = 0; i < code.length; i++) {
|
|
92
|
+
const glyph = FONT[code[i]];
|
|
93
|
+
if (!glyph) {
|
|
94
|
+
throw new Error(`[kCAPTCHA] No glyph for character "${code[i]}".`);
|
|
95
|
+
}
|
|
96
|
+
const tf = randomTransform();
|
|
97
|
+
for (const seg of glyph) {
|
|
98
|
+
pipes.push(samplePipe(seg, tf, i, phase));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
shuffle(pipes);
|
|
102
|
+
return {
|
|
103
|
+
width: Math.ceil((code.length - 1) * STEP + GLYPH_WIDTH + 2 * PAD_X),
|
|
104
|
+
height: GLYPH_HEIGHT + 2 * PAD_Y,
|
|
105
|
+
spacing: DOT_SPACING,
|
|
106
|
+
pipes,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
//# sourceMappingURL=geometry.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"geometry.js","sourceRoot":"","sources":["../src/geometry.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { resolveConfig, DEFAULTS, KCaptchaConfigError } from './config.js';
|
|
2
|
+
export type { KCaptchaOptions, ResolvedConfig, KCaptchaStore, RateLimitOptions, } from './config.js';
|
|
3
|
+
export { createChallenge } from './challenge.js';
|
|
4
|
+
export type { Challenge } from './challenge.js';
|
|
5
|
+
export type { Geometry } from './geometry.js';
|
|
6
|
+
export { verifyAnswer, consumePass } from './verify.js';
|
|
7
|
+
export type { VerifyResult, VerifyFailure, PassResult, PassFailure } from './verify.js';
|
|
8
|
+
export { checkRateLimit } from './rate-limit.js';
|
|
9
|
+
export type { RateLimitBucket, RateLimitResult } from './rate-limit.js';
|
|
10
|
+
export { MemoryStore } from './stores/memory.js';
|
|
11
|
+
export type { MemoryStoreOptions } from './stores/memory.js';
|
|
12
|
+
export { RedisStore } from './stores/redis.js';
|
|
13
|
+
export type { RedisCommands, IoRedisLike, NodeRedisLike } from './stores/redis.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { resolveConfig, DEFAULTS, KCaptchaConfigError } from './config.js';
|
|
2
|
+
export { createChallenge } from './challenge.js';
|
|
3
|
+
export { verifyAnswer, consumePass } from './verify.js';
|
|
4
|
+
export { checkRateLimit } from './rate-limit.js';
|
|
5
|
+
export { MemoryStore } from './stores/memory.js';
|
|
6
|
+
export { RedisStore } from './stores/redis.js';
|
|
7
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,QAAQ,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAQ3E,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAIjD,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAGxD,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAGjD,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAGjD,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ResolvedConfig } from './config.js';
|
|
2
|
+
/** Each endpoint has its own counter, so solving attempts don't starve new-code requests. */
|
|
3
|
+
export type RateLimitBucket = 'new-code' | 'verify-captcha';
|
|
4
|
+
export interface RateLimitResult {
|
|
5
|
+
allowed: boolean;
|
|
6
|
+
limit: number;
|
|
7
|
+
remaining: number;
|
|
8
|
+
/** Seconds until the current window resets. */
|
|
9
|
+
retryAfter: number;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Fixed-window limiter. Store errors propagate: adapters must fail closed.
|
|
13
|
+
*/
|
|
14
|
+
export declare function checkRateLimit(config: ResolvedConfig, bucket: RateLimitBucket, ip: string): Promise<RateLimitResult>;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createHmac } from 'node:crypto';
|
|
2
|
+
function clientKey(secret, ip) {
|
|
3
|
+
return createHmac('sha256', secret)
|
|
4
|
+
.update(`kcaptcha/v1/client:${ip}`)
|
|
5
|
+
.digest('hex')
|
|
6
|
+
.slice(0, 32);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Fixed-window limiter. Store errors propagate: adapters must fail closed.
|
|
10
|
+
*/
|
|
11
|
+
export async function checkRateLimit(config, bucket, ip) {
|
|
12
|
+
const { max, windowSeconds } = config.rateLimit;
|
|
13
|
+
const now = Math.floor(Date.now() / 1000);
|
|
14
|
+
const windowIndex = Math.floor(now / windowSeconds);
|
|
15
|
+
const key = `rl:${bucket}:${windowIndex}:${clientKey(config.secret, ip)}`;
|
|
16
|
+
const count = await config.store.incr(key, windowSeconds + 1);
|
|
17
|
+
return {
|
|
18
|
+
allowed: count <= max,
|
|
19
|
+
limit: max,
|
|
20
|
+
remaining: Math.max(0, max - count),
|
|
21
|
+
retryAfter: windowSeconds - (now % windowSeconds),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=rate-limit.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rate-limit.js","sourceRoot":"","sources":["../src/rate-limit.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAczC,SAAS,SAAS,CAAC,MAAc,EAAE,EAAU;IAC3C,OAAO,UAAU,CAAC,QAAQ,EAAE,MAAM,CAAC;SAChC,MAAM,CAAC,sBAAsB,EAAE,EAAE,CAAC;SAClC,MAAM,CAAC,KAAK,CAAC;SACb,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;AAClB,CAAC;AAED;;GAEG;AACH,MAAM,CAAC,KAAK,UAAU,cAAc,CAClC,MAAsB,EACtB,MAAuB,EACvB,EAAU;IAEV,MAAM,EAAE,GAAG,EAAE,aAAa,EAAE,GAAG,MAAM,CAAC,SAAS,CAAC;IAChD,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,CAAC;IAC1C,MAAM,WAAW,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,GAAG,aAAa,CAAC,CAAC;IAEpD,MAAM,GAAG,GAAG,MAAM,MAAM,IAAI,WAAW,IAAI,SAAS,CAAC,MAAM,CAAC,MAAM,EAAE,EAAE,CAAC,EAAE,CAAC;IAC1E,MAAM,KAAK,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,EAAE,aAAa,GAAG,CAAC,CAAC,CAAC;IAE9D,OAAO;QACL,OAAO,EAAE,KAAK,IAAI,GAAG;QACrB,KAAK,EAAE,GAAG;QACV,SAAS,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,GAAG,GAAG,KAAK,CAAC;QACnC,UAAU,EAAE,aAAa,GAAG,CAAC,GAAG,GAAG,aAAa,CAAC;KAClD,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { KCaptchaStore } from '../config.js';
|
|
2
|
+
export interface MemoryStoreOptions {
|
|
3
|
+
/** Hard cap on stored keys. Default 100000. */
|
|
4
|
+
maxEntries?: number;
|
|
5
|
+
/** How often expired keys are swept. Default 30 s. */
|
|
6
|
+
sweepIntervalSeconds?: number;
|
|
7
|
+
}
|
|
8
|
+
export declare class MemoryStore implements KCaptchaStore {
|
|
9
|
+
private readonly entries;
|
|
10
|
+
private readonly maxEntries;
|
|
11
|
+
private readonly timer;
|
|
12
|
+
constructor(options?: MemoryStoreOptions);
|
|
13
|
+
incr(key: string, ttlSeconds: number): Promise<number>;
|
|
14
|
+
setOnce(key: string, ttlSeconds: number): Promise<boolean>;
|
|
15
|
+
/** Remove expired keys. Runs on a timer and when the store is full. */
|
|
16
|
+
sweep(now?: number): void;
|
|
17
|
+
/** Stop the sweep timer and clear all data (useful in tests and on shutdown). */
|
|
18
|
+
close(): void;
|
|
19
|
+
get size(): number;
|
|
20
|
+
private live;
|
|
21
|
+
private hasRoom;
|
|
22
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export class MemoryStore {
|
|
2
|
+
entries = new Map();
|
|
3
|
+
maxEntries;
|
|
4
|
+
timer;
|
|
5
|
+
constructor(options = {}) {
|
|
6
|
+
this.maxEntries = options.maxEntries ?? 100_000;
|
|
7
|
+
this.timer = setInterval(() => this.sweep(), (options.sweepIntervalSeconds ?? 30) * 1000);
|
|
8
|
+
this.timer.unref(); // never keep the process alive just for cleanup
|
|
9
|
+
}
|
|
10
|
+
async incr(key, ttlSeconds) {
|
|
11
|
+
const now = Date.now();
|
|
12
|
+
const existing = this.live(key, now);
|
|
13
|
+
if (existing) {
|
|
14
|
+
existing.value += 1;
|
|
15
|
+
return existing.value;
|
|
16
|
+
}
|
|
17
|
+
// Full: fail closed by reporting an over-limit count.
|
|
18
|
+
if (!this.hasRoom(now))
|
|
19
|
+
return Number.MAX_SAFE_INTEGER;
|
|
20
|
+
this.entries.set(key, { value: 1, expiresAt: now + ttlSeconds * 1000 });
|
|
21
|
+
return 1;
|
|
22
|
+
}
|
|
23
|
+
async setOnce(key, ttlSeconds) {
|
|
24
|
+
const now = Date.now();
|
|
25
|
+
if (this.live(key, now))
|
|
26
|
+
return false;
|
|
27
|
+
// Full: fail closed by refusing (treated as "already used").
|
|
28
|
+
if (!this.hasRoom(now))
|
|
29
|
+
return false;
|
|
30
|
+
this.entries.set(key, { value: 1, expiresAt: now + ttlSeconds * 1000 });
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
/** Remove expired keys. Runs on a timer and when the store is full. */
|
|
34
|
+
sweep(now = Date.now()) {
|
|
35
|
+
for (const [key, entry] of this.entries) {
|
|
36
|
+
if (entry.expiresAt <= now)
|
|
37
|
+
this.entries.delete(key);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Stop the sweep timer and clear all data (useful in tests and on shutdown). */
|
|
41
|
+
close() {
|
|
42
|
+
clearInterval(this.timer);
|
|
43
|
+
this.entries.clear();
|
|
44
|
+
}
|
|
45
|
+
get size() {
|
|
46
|
+
return this.entries.size;
|
|
47
|
+
}
|
|
48
|
+
live(key, now) {
|
|
49
|
+
const entry = this.entries.get(key);
|
|
50
|
+
if (!entry)
|
|
51
|
+
return undefined;
|
|
52
|
+
if (entry.expiresAt <= now) {
|
|
53
|
+
this.entries.delete(key);
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
return entry;
|
|
57
|
+
}
|
|
58
|
+
hasRoom(now) {
|
|
59
|
+
if (this.entries.size < this.maxEntries)
|
|
60
|
+
return true;
|
|
61
|
+
this.sweep(now);
|
|
62
|
+
return this.entries.size < this.maxEntries;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
//# sourceMappingURL=memory.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"memory.js","sourceRoot":"","sources":["../../src/stores/memory.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { KCaptchaStore } from '../config.js';
|
|
2
|
+
/** The two primitives the store needs. Implement these to support any Redis client. */
|
|
3
|
+
export interface RedisCommands {
|
|
4
|
+
/** Atomically INCR and set the TTL if the key is new. Returns the new count. */
|
|
5
|
+
incrWithTtl(key: string, ttlSeconds: number): Promise<number>;
|
|
6
|
+
/** SET key NX EX ttl. Returns true only if the key did not exist. */
|
|
7
|
+
setNx(key: string, ttlSeconds: number): Promise<boolean>;
|
|
8
|
+
}
|
|
9
|
+
export interface IoRedisLike {
|
|
10
|
+
eval(...args: any[]): Promise<unknown>;
|
|
11
|
+
set(...args: any[]): Promise<unknown>;
|
|
12
|
+
}
|
|
13
|
+
export interface NodeRedisLike {
|
|
14
|
+
eval(...args: any[]): Promise<unknown>;
|
|
15
|
+
set(...args: any[]): Promise<unknown>;
|
|
16
|
+
}
|
|
17
|
+
export declare class RedisStore implements KCaptchaStore {
|
|
18
|
+
private readonly commands;
|
|
19
|
+
private readonly prefix;
|
|
20
|
+
constructor(commands: RedisCommands, prefix?: string);
|
|
21
|
+
/** ioredis: `RedisStore.fromIoRedis(new Redis(url))` */
|
|
22
|
+
static fromIoRedis(client: IoRedisLike, prefix?: string): RedisStore;
|
|
23
|
+
/** node-redis v4+: `RedisStore.fromNodeRedis(createClient({ url }))` */
|
|
24
|
+
static fromNodeRedis(client: NodeRedisLike, prefix?: string): RedisStore;
|
|
25
|
+
incr(key: string, ttlSeconds: number): Promise<number>;
|
|
26
|
+
setOnce(key: string, ttlSeconds: number): Promise<boolean>;
|
|
27
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const INCR_SCRIPT = `
|
|
2
|
+
local c = redis.call('INCR', KEYS[1])
|
|
3
|
+
if c == 1 then redis.call('EXPIRE', KEYS[1], tonumber(ARGV[1])) end
|
|
4
|
+
return c
|
|
5
|
+
`;
|
|
6
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
7
|
+
const ttl = (seconds) => Math.max(1, Math.ceil(seconds));
|
|
8
|
+
export class RedisStore {
|
|
9
|
+
commands;
|
|
10
|
+
prefix;
|
|
11
|
+
constructor(commands, prefix = 'kcaptcha:') {
|
|
12
|
+
this.commands = commands;
|
|
13
|
+
this.prefix = prefix;
|
|
14
|
+
}
|
|
15
|
+
/** ioredis: `RedisStore.fromIoRedis(new Redis(url))` */
|
|
16
|
+
static fromIoRedis(client, prefix) {
|
|
17
|
+
return new RedisStore({
|
|
18
|
+
async incrWithTtl(key, seconds) {
|
|
19
|
+
return Number(await client.eval(INCR_SCRIPT, 1, key, String(ttl(seconds))));
|
|
20
|
+
},
|
|
21
|
+
async setNx(key, seconds) {
|
|
22
|
+
return (await client.set(key, '1', 'EX', ttl(seconds), 'NX')) === 'OK';
|
|
23
|
+
},
|
|
24
|
+
}, prefix);
|
|
25
|
+
}
|
|
26
|
+
/** node-redis v4+: `RedisStore.fromNodeRedis(createClient({ url }))` */
|
|
27
|
+
static fromNodeRedis(client, prefix) {
|
|
28
|
+
return new RedisStore({
|
|
29
|
+
async incrWithTtl(key, seconds) {
|
|
30
|
+
return Number(await client.eval(INCR_SCRIPT, { keys: [key], arguments: [String(ttl(seconds))] }));
|
|
31
|
+
},
|
|
32
|
+
async setNx(key, seconds) {
|
|
33
|
+
return (await client.set(key, '1', { EX: ttl(seconds), NX: true })) === 'OK';
|
|
34
|
+
},
|
|
35
|
+
}, prefix);
|
|
36
|
+
}
|
|
37
|
+
incr(key, ttlSeconds) {
|
|
38
|
+
return this.commands.incrWithTtl(this.prefix + key, ttlSeconds);
|
|
39
|
+
}
|
|
40
|
+
setOnce(key, ttlSeconds) {
|
|
41
|
+
return this.commands.setNx(this.prefix + key, ttlSeconds);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=redis.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"redis.js","sourceRoot":"","sources":["../../src/stores/redis.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,GAAG;;;;CAInB,CAAC;AAoBF,sDAAsD;AAEtD,MAAM,GAAG,GAAG,CAAC,OAAe,EAAU,EAAE,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC;AAEzE,MAAM,OAAO,UAAU;IAEF;IACA;IAFnB,YACmB,QAAuB,EACvB,SAAiB,WAAW;QAD5B,aAAQ,GAAR,QAAQ,CAAe;QACvB,WAAM,GAAN,MAAM,CAAsB;IAC5C,CAAC;IAEJ,wDAAwD;IACxD,MAAM,CAAC,WAAW,CAAC,MAAmB,EAAE,MAAe;QACrD,OAAO,IAAI,UAAU,CACnB;YACE,KAAK,CAAC,WAAW,CAAC,GAAG,EAAE,OAAO;gBAC5B,OAAO,MAAM,CAAC,MAAM,MAAM,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC,EAAE,GAAG,EAAE,MAAM,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC;YAC9E,CAAC;YACD,KAAK,CAAC,KAAK,CAAC,GAAG,EAAE,OAAO;gBACtB,OAAO,CAAC,MAAM,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,GAAG,EAAE,IAAI,EAAE,GAAG,CAAC,OAAO,CAAC,EAAE,IAAI,CAAC,CAAC,KAAK,IAAI,CAAC;YACzE,CAAC;SACF,EACD,MAAM,CACP,CAAC;IACJ,CAAC;IAED,wEAAwE;IACxE,MAAM,CAAC,aAAa,CAAC,MAAqB,EAAE,MAAe;QACzD,OAAO,IAAI,UAAU,CACnB;YACE,KAAK,CAAC,WAAW,CAAC,GAAG,EAAE,OAAO;gBAC5B,OAAO,MAAM,CACX,MAAM,MAAM,CAAC,IAAI,CAAC,WAAW,EAAE,EAAE,IAAI,EAAE,CAAC,GAAG,CAAC,EAAE,SAAS,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,CACnF,CAAC;YACJ,CAAC;YACD,KAAK,CAAC,KAAK,CAAC,GAAG,EAAE,OAAO;gBACtB,OAAO,CAAC,MAAM,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE,EAAE,EAAE,GAAG,CAAC,OAAO,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,KAAK,IAAI,CAAC;YAC/E,CAAC;SACF,EACD,MAAM,CACP,CAAC;IACJ,CAAC;IAED,IAAI,CAAC,GAAW,EAAE,UAAkB;QAClC,OAAO,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC,IAAI,CAAC,MAAM,GAAG,GAAG,EAAE,UAAU,CAAC,CAAC;IAClE,CAAC;IAED,OAAO,CAAC,GAAW,EAAE,UAAkB;QACrC,OAAO,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,GAAG,GAAG,EAAE,UAAU,CAAC,CAAC;IAC5D,CAAC;CACF"}
|
package/dist/token.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type TokenPurpose = 'challenge' | 'pass';
|
|
2
|
+
export interface ChallengePayload {
|
|
3
|
+
t: 'challenge';
|
|
4
|
+
/** Unique challenge id (also the replay-protection key). */
|
|
5
|
+
id: string;
|
|
6
|
+
/** Expiry, unix seconds. */
|
|
7
|
+
exp: number;
|
|
8
|
+
/** HMAC of the correct answer. Useless without the server secret. */
|
|
9
|
+
mac: string;
|
|
10
|
+
}
|
|
11
|
+
export interface PassPayload {
|
|
12
|
+
t: 'pass';
|
|
13
|
+
/** The id of the challenge that was solved. */
|
|
14
|
+
id: string;
|
|
15
|
+
exp: number;
|
|
16
|
+
}
|
|
17
|
+
export type PayloadFor<P extends TokenPurpose> = P extends 'challenge' ? ChallengePayload : PassPayload;
|
|
18
|
+
export type TokenError = 'invalid' | 'expired';
|
|
19
|
+
export type TokenResult<P extends TokenPurpose> = {
|
|
20
|
+
ok: true;
|
|
21
|
+
payload: PayloadFor<P>;
|
|
22
|
+
} | {
|
|
23
|
+
ok: false;
|
|
24
|
+
reason: TokenError;
|
|
25
|
+
};
|
|
26
|
+
export declare const nowSeconds: () => number;
|
|
27
|
+
export declare const randomId: () => string;
|
|
28
|
+
export declare function signToken<P extends TokenPurpose>(secret: string, purpose: P, payload: PayloadFor<P>): string;
|
|
29
|
+
export declare function verifyToken<P extends TokenPurpose>(secret: string, purpose: P, token: string): TokenResult<P>;
|
|
30
|
+
export declare function answerMac(secret: string, id: string, code: string): string;
|
|
31
|
+
export declare function macMatches(secret: string, id: string, code: string, mac: string): boolean;
|
package/dist/token.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
const MAX_TOKEN_LENGTH = 1024;
|
|
3
|
+
export const nowSeconds = () => Math.floor(Date.now() / 1000);
|
|
4
|
+
export const randomId = () => randomBytes(16).toString('base64url');
|
|
5
|
+
// Domain separation: every purpose signs with its own key derived from the secret.
|
|
6
|
+
function deriveKey(secret, label) {
|
|
7
|
+
return createHmac('sha256', secret).update(`kcaptcha/v1/${label}`).digest();
|
|
8
|
+
}
|
|
9
|
+
function safeEqual(a, b) {
|
|
10
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
11
|
+
}
|
|
12
|
+
export function signToken(secret, purpose, payload) {
|
|
13
|
+
const body = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
|
|
14
|
+
const sig = createHmac('sha256', deriveKey(secret, `token:${purpose}`))
|
|
15
|
+
.update(body)
|
|
16
|
+
.digest('base64url');
|
|
17
|
+
return `${body}.${sig}`;
|
|
18
|
+
}
|
|
19
|
+
export function verifyToken(secret, purpose, token) {
|
|
20
|
+
if (token.length === 0 || token.length > MAX_TOKEN_LENGTH) {
|
|
21
|
+
return { ok: false, reason: 'invalid' };
|
|
22
|
+
}
|
|
23
|
+
const parts = token.split('.');
|
|
24
|
+
if (parts.length !== 2)
|
|
25
|
+
return { ok: false, reason: 'invalid' };
|
|
26
|
+
const [body, sig] = parts;
|
|
27
|
+
// Check the signature over the exact string received BEFORE parsing anything.
|
|
28
|
+
const expected = createHmac('sha256', deriveKey(secret, `token:${purpose}`))
|
|
29
|
+
.update(body)
|
|
30
|
+
.digest();
|
|
31
|
+
if (!safeEqual(Buffer.from(sig, 'base64url'), expected)) {
|
|
32
|
+
return { ok: false, reason: 'invalid' };
|
|
33
|
+
}
|
|
34
|
+
let parsed;
|
|
35
|
+
try {
|
|
36
|
+
parsed = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return { ok: false, reason: 'invalid' };
|
|
40
|
+
}
|
|
41
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
42
|
+
return { ok: false, reason: 'invalid' };
|
|
43
|
+
const o = parsed;
|
|
44
|
+
const shapeOk = o.t === purpose &&
|
|
45
|
+
typeof o.id === 'string' &&
|
|
46
|
+
typeof o.exp === 'number' &&
|
|
47
|
+
Number.isFinite(o.exp) &&
|
|
48
|
+
(purpose !== 'challenge' || typeof o.mac === 'string');
|
|
49
|
+
if (!shapeOk)
|
|
50
|
+
return { ok: false, reason: 'invalid' };
|
|
51
|
+
if (nowSeconds() >= o.exp)
|
|
52
|
+
return { ok: false, reason: 'expired' };
|
|
53
|
+
return { ok: true, payload: parsed };
|
|
54
|
+
}
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Answer MAC. The code is never stored or sent; only this keyed hash is.
|
|
57
|
+
// Because it is an HMAC under the server secret, an attacker who reads the
|
|
58
|
+
// token cannot brute-force the small code space offline.
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
export function answerMac(secret, id, code) {
|
|
61
|
+
return createHmac('sha256', deriveKey(secret, 'answer'))
|
|
62
|
+
.update(`${id}\0${code}`)
|
|
63
|
+
.digest('base64url');
|
|
64
|
+
}
|
|
65
|
+
export function macMatches(secret, id, code, mac) {
|
|
66
|
+
return safeEqual(Buffer.from(answerMac(secret, id, code)), Buffer.from(mac));
|
|
67
|
+
}
|
|
68
|
+
//# sourceMappingURL=token.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"token.js","sourceRoot":"","sources":["../src/token.ts"],"names":[],"mappings":"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"}
|
package/dist/verify.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ResolvedConfig } from './config.js';
|
|
2
|
+
export type VerifyFailure = 'malformed' | 'invalid' | 'expired' | 'replayed' | 'incorrect';
|
|
3
|
+
export type VerifyResult = {
|
|
4
|
+
ok: true;
|
|
5
|
+
pass: string;
|
|
6
|
+
expiresIn: number;
|
|
7
|
+
} | {
|
|
8
|
+
ok: false;
|
|
9
|
+
reason: VerifyFailure;
|
|
10
|
+
};
|
|
11
|
+
export type PassFailure = 'malformed' | 'invalid' | 'expired' | 'replayed';
|
|
12
|
+
export type PassResult = {
|
|
13
|
+
ok: true;
|
|
14
|
+
} | {
|
|
15
|
+
ok: false;
|
|
16
|
+
reason: PassFailure;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Verify a challenge token + the user's answer.
|
|
20
|
+
* Store errors propagate: adapters must fail closed (respond 503).
|
|
21
|
+
*/
|
|
22
|
+
export declare function verifyAnswer(config: ResolvedConfig, token: unknown, answer: unknown): Promise<VerifyResult>;
|
|
23
|
+
/**
|
|
24
|
+
* Check and consume a pass token. Call this from the protected route
|
|
25
|
+
* (login, signup, ...). A pass works exactly once.
|
|
26
|
+
*/
|
|
27
|
+
export declare function consumePass(config: ResolvedConfig, token: unknown): Promise<PassResult>;
|
package/dist/verify.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { macMatches, nowSeconds, signToken, verifyToken } from './token.js';
|
|
2
|
+
const MAX_ANSWER_LENGTH = 16;
|
|
3
|
+
// Small grace so a marker never disappears a moment before its token expires.
|
|
4
|
+
const TTL_GRACE_SECONDS = 5;
|
|
5
|
+
/**
|
|
6
|
+
* Verify a challenge token + the user's answer.
|
|
7
|
+
* Store errors propagate: adapters must fail closed (respond 503).
|
|
8
|
+
*/
|
|
9
|
+
export async function verifyAnswer(config, token, answer) {
|
|
10
|
+
if (typeof token !== 'string' || typeof answer !== 'string') {
|
|
11
|
+
return { ok: false, reason: 'malformed' };
|
|
12
|
+
}
|
|
13
|
+
const guess = answer.replace(/\s+/g, '').toUpperCase();
|
|
14
|
+
if (guess.length === 0 || guess.length > MAX_ANSWER_LENGTH) {
|
|
15
|
+
return { ok: false, reason: 'malformed' };
|
|
16
|
+
}
|
|
17
|
+
const checked = verifyToken(config.secret, 'challenge', token);
|
|
18
|
+
if (!checked.ok)
|
|
19
|
+
return { ok: false, reason: checked.reason };
|
|
20
|
+
const { id, exp, mac } = checked.payload;
|
|
21
|
+
// Burn the challenge FIRST: one guess per challenge, right or wrong.
|
|
22
|
+
const ttl = Math.max(1, exp - nowSeconds()) + TTL_GRACE_SECONDS;
|
|
23
|
+
const firstUse = await config.store.setOnce(`used:${id}`, ttl);
|
|
24
|
+
if (!firstUse)
|
|
25
|
+
return { ok: false, reason: 'replayed' };
|
|
26
|
+
if (!macMatches(config.secret, id, guess, mac)) {
|
|
27
|
+
return { ok: false, reason: 'incorrect' };
|
|
28
|
+
}
|
|
29
|
+
const pass = signToken(config.secret, 'pass', {
|
|
30
|
+
t: 'pass',
|
|
31
|
+
id,
|
|
32
|
+
exp: nowSeconds() + config.passExpire,
|
|
33
|
+
});
|
|
34
|
+
return { ok: true, pass, expiresIn: config.passExpire };
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Check and consume a pass token. Call this from the protected route
|
|
38
|
+
* (login, signup, ...). A pass works exactly once.
|
|
39
|
+
*/
|
|
40
|
+
export async function consumePass(config, token) {
|
|
41
|
+
if (typeof token !== 'string')
|
|
42
|
+
return { ok: false, reason: 'malformed' };
|
|
43
|
+
const checked = verifyToken(config.secret, 'pass', token);
|
|
44
|
+
if (!checked.ok)
|
|
45
|
+
return { ok: false, reason: checked.reason };
|
|
46
|
+
const { id, exp } = checked.payload;
|
|
47
|
+
const ttl = Math.max(1, exp - nowSeconds()) + TTL_GRACE_SECONDS;
|
|
48
|
+
const firstUse = await config.store.setOnce(`pass:${id}`, ttl);
|
|
49
|
+
if (!firstUse)
|
|
50
|
+
return { ok: false, reason: 'replayed' };
|
|
51
|
+
return { ok: true };
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=verify.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"verify.js","sourceRoot":"","sources":["../src/verify.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kcaptcha/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Framework-agnostic server logic for kCAPTCHA",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": ["dist"],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc -p tsconfig.json",
|
|
18
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=18"
|
|
22
|
+
}
|
|
23
|
+
}
|