diff --git a/README.md b/README.md index 2162866..08d892e 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,12 @@ ALTCHA JS Library is a lightweight, zero-dependency library designed for creatin ## Compatibility -This library utilizes [Web Crypto](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto) and is intended for server-side use. +This library utilizes [Web Crypto](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto). - Node.js 16+ - Bun 1+ - Deno 1+ +- All modern browsers ## Usage @@ -37,15 +38,27 @@ Parameters: - `options: ChallengeOptions`: - `algorithm?: string`: Algorithm to use (`SHA-1`, `SHA-256`, `SHA-512`, default: `SHA-256`). + - `expires?: Date`: Optional `expires` time (as `Date` set into the future date). - `hmacKey: string` (required): Signature HMAC key. - - `maxNumber?: number` Optional maximum number for the random number generator (defaults to 1,000,000). + - `maxnumber?: number`: Optional maximum number for the random number generator (defaults to 1,000,000). - `number?: number`: Optional number to use. If not provided, a random number will be generated. + - `params?: Record`: Optional parameters to be added to the salt as URL-encoded query string. Use `extractParams()` to read them. - `salt?: string`: Optional salt string. If not provided, a random salt will be generated. - - `saltLength?: number` Optional maximum lenght of the random salt (in bytes, defaults to 12). + - `saltLength?: number`: Optional maximum lenght of the random salt (in bytes, defaults to 12). Returns: `Promise` -### `verifySolution(payload, hmacKey)` +### `extractParams(payload)` + +Extracts optional parameters from the challenge or payload. + +Parameters: + +- `payload: string | Payload | Challenge` + +Returns: `Record` + +### `verifySolution(payload, hmacKey, checkExpires = true)` Verifies an ALTCHA solution. The payload can be a Base64-encoded JSON payload (as submitted by the widget) or an object. @@ -53,6 +66,7 @@ Parameters: - `payload: string | Payload` - `hmacKey: string` +- `checkExpires: boolean = true`: Whether to perform a check on the optional `expires` parameter. Will return `false` if challenge expired. Returns: `Promise` @@ -65,7 +79,7 @@ Parameters: - `challenge: string` (required): The challenge hash. - `salt: string` (required): The challenge salt. - `algorithm?: string`: Optional algorithm (default: `SHA-256`). -- `max?: string`: Optional `maxnumber` to iterate to (default: 1e6). +- `maxnumber?: string`: Optional `maxnumber` to iterate to (default: 1e6). - `start?: string`: Optional starting number (default: 0). Returns: `{ controller: AbortController, promise: Promise }` @@ -92,7 +106,7 @@ Parameters: - `challenge: string` (required): The challenge hash. - `salt: string` (required): The challenge salt. - `algorithm?: string`: Optional algorithm (default: `SHA-256`). -- `max?: string`: Optional `maxnumber` to iterate to (default: 1e6). +- `maxnumber?: string`: Optional `maxnumber` to iterate to (default: 1e6). - `start?: string`: Optional starting number (default: 0). Returns: `Promise` @@ -114,16 +128,18 @@ const solution = await solveChallengeWorkers( ``` > solveChallenge() -- n = 1,000............................... 317 ops/s ±2.63% -- n = 10,000.............................. 32 ops/s ±1.88% -- n = 100,000............................. 3 ops/s ±0.34% -- n = 500,000............................. 0 ops/s ±0.32% +- n = 1,000............................... 312 ops/s ±2.90% +- n = 10,000.............................. 31 ops/s ±1.50% +- n = 50,000.............................. 6 ops/s ±0.82% +- n = 100,000............................. 3 ops/s ±0.37% +- n = 500,000............................. 0 ops/s ±0.31% > solveChallengeWorkers() (8 workers) -- n = 1,000............................... 66 ops/s ±3.44% -- n = 10,000.............................. 31 ops/s ±4.28% -- n = 100,000............................. 7 ops/s ±4.40% -- n = 500,000............................. 1 ops/s ±2.49% +- n = 1,000............................... 62 ops/s ±3.99% +- n = 10,000.............................. 31 ops/s ±6.83% +- n = 50,000.............................. 11 ops/s ±4.00% +- n = 100,000............................. 7 ops/s ±2.32% +- n = 500,000............................. 1 ops/s ±1.89% ``` Run with Bun on MacBook Pro M3-Pro. See [/benchmark](/benchmark/) folder for more details. diff --git a/benchmark/bench.ts b/benchmark/bench.ts index 5e432e0..37f9790 100644 --- a/benchmark/bench.ts +++ b/benchmark/bench.ts @@ -67,7 +67,7 @@ await benchmark(`solveChallengeWorkers() (${workers} workers)`, (bench) => { challenge1.challenge, challenge1.salt, challenge1.algorithm, - challenge1.max, + challenge1.maxnumber, ); }) .add('n = 10,000', async () => { @@ -77,7 +77,7 @@ await benchmark(`solveChallengeWorkers() (${workers} workers)`, (bench) => { challenge2.challenge, challenge2.salt, challenge2.algorithm, - challenge2.max, + challenge2.maxnumber, ); }) .add('n = 50,000', async () => { @@ -87,7 +87,7 @@ await benchmark(`solveChallengeWorkers() (${workers} workers)`, (bench) => { challenge3.challenge, challenge3.salt, challenge3.algorithm, - challenge3.max, + challenge3.maxnumber, ); }) .add('n = 100,000', async () => { @@ -97,7 +97,7 @@ await benchmark(`solveChallengeWorkers() (${workers} workers)`, (bench) => { challenge4.challenge, challenge4.salt, challenge4.algorithm, - challenge4.max, + challenge4.maxnumber, ); }) .add('n = 500,000', async () => { @@ -107,7 +107,7 @@ await benchmark(`solveChallengeWorkers() (${workers} workers)`, (bench) => { challenge5.challenge, challenge5.salt, challenge5.algorithm, - challenge5.max, + challenge5.maxnumber, ); }); }); diff --git a/cjs/dist/index.d.ts b/cjs/dist/index.d.ts index 1fd3d83..3e8c9bc 100644 --- a/cjs/dist/index.d.ts +++ b/cjs/dist/index.d.ts @@ -1,6 +1,9 @@ import type { Challenge, ChallengeOptions, Payload, ServerSignaturePayload, ServerSignatureVerificationData, Solution } from './types.js'; export declare function createChallenge(options: ChallengeOptions): Promise; -export declare function verifySolution(payload: string | Payload, hmacKey: string): Promise; +export declare function extractParams(payload: string | Payload | Challenge): { + [k: string]: string; +}; +export declare function verifySolution(payload: string | Payload, hmacKey: string, checkExpires?: boolean): Promise; export declare function verifyServerSignature(payload: string | ServerSignaturePayload, hmacKey: string): Promise<{ verificationData: ServerSignatureVerificationData | null; verified: boolean | null; @@ -12,6 +15,7 @@ export declare function solveChallenge(challenge: string, salt: string, algorith export declare function solveChallengeWorkers(workerScript: string | URL | (() => Worker), concurrency: number, challenge: string, salt: string, algorithm?: string, max?: number, startNumber?: number): Promise; declare const _default: { createChallenge: typeof createChallenge; + extractParams: typeof extractParams; solveChallenge: typeof solveChallenge; solveChallengeWorkers: typeof solveChallengeWorkers; verifyServerSignature: typeof verifyServerSignature; diff --git a/cjs/dist/index.js b/cjs/dist/index.js index 2564fc8..d3b8f80 100644 --- a/cjs/dist/index.js +++ b/cjs/dist/index.js @@ -1,6 +1,6 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.solveChallengeWorkers = exports.solveChallenge = exports.verifyServerSignature = exports.verifySolution = exports.createChallenge = void 0; +exports.solveChallengeWorkers = exports.solveChallenge = exports.verifyServerSignature = exports.verifySolution = exports.extractParams = exports.createChallenge = void 0; const helpers_js_1 = require("./helpers.js"); const DEFAULT_MAX_NUMBER = 1e6; const DEFAULT_SALT_LEN = 12; @@ -9,7 +9,14 @@ async function createChallenge(options) { const algorithm = options.algorithm || DEFAULT_ALG; const maxnumber = options.maxnumber || options.maxNumber || DEFAULT_MAX_NUMBER; const saltLength = options.saltLength || DEFAULT_SALT_LEN; - const salt = options.salt || (0, helpers_js_1.ab2hex)((0, helpers_js_1.randomBytes)(saltLength)); + const params = new URLSearchParams(options.params); + if (options.expires) { + params.set('expires', String(Math.floor(options.expires.getTime() / 1000))); + } + let salt = options.salt || (0, helpers_js_1.ab2hex)((0, helpers_js_1.randomBytes)(saltLength)); + if (params.size) { + salt = salt + '?' + params.toString(); + } const number = options.number === void 0 ? (0, helpers_js_1.randomInt)(maxnumber) : options.number; const challenge = await (0, helpers_js_1.hashHex)(algorithm, salt + number); return { @@ -21,10 +28,25 @@ async function createChallenge(options) { }; } exports.createChallenge = createChallenge; -async function verifySolution(payload, hmacKey) { +function extractParams(payload) { if (typeof payload === 'string') { payload = JSON.parse(atob(payload)); } + return Object.fromEntries(new URLSearchParams(payload.salt.split('?')?.[1] || '')); +} +exports.extractParams = extractParams; +async function verifySolution(payload, hmacKey, checkExpires = true) { + if (typeof payload === 'string') { + payload = JSON.parse(atob(payload)); + } + const params = extractParams(payload); + const expires = params.expires || params.expire; + if (checkExpires && expires) { + const date = new Date(parseInt(expires, 10) * 1000); + if (!isNaN(date.getTime()) && date.getTime() < Date.now()) { + return false; + } + } const check = await createChallenge({ algorithm: payload.algorithm, hmacKey, @@ -70,32 +92,24 @@ async function verifyServerSignature(payload, hmacKey) { exports.verifyServerSignature = verifyServerSignature; function solveChallenge(challenge, salt, algorithm = 'SHA-256', max = 1e6, start = 0) { const controller = new AbortController(); - const promise = new Promise((resolve, reject) => { - const startTime = Date.now(); - const next = (n) => { - if (controller.signal.aborted || n > max) { - resolve(null); + const startTime = Date.now(); + const fn = async () => { + for (let n = start; n <= max; n += 1) { + if (controller.signal.aborted) { + return null; } - else { - (0, helpers_js_1.hashHex)(algorithm, salt + n) - .then((t) => { - if (t === challenge) { - resolve({ - number: n, - took: Date.now() - startTime, - }); - } - else { - next(n + 1); - } - }) - .catch(reject); + const t = await (0, helpers_js_1.hashHex)(algorithm, salt + n); + if (t === challenge) { + return { + number: n, + took: Date.now() - startTime, + }; } - }; - next(start); - }); + } + return null; + }; return { - promise, + promise: fn(), controller, }; } @@ -152,6 +166,7 @@ async function solveChallengeWorkers(workerScript, concurrency, challenge, salt, exports.solveChallengeWorkers = solveChallengeWorkers; exports.default = { createChallenge, + extractParams, solveChallenge, solveChallengeWorkers, verifyServerSignature, diff --git a/cjs/dist/types.d.ts b/cjs/dist/types.d.ts index bf0a2c5..29259a1 100644 --- a/cjs/dist/types.d.ts +++ b/cjs/dist/types.d.ts @@ -8,10 +8,12 @@ export interface Challenge { } export interface ChallengeOptions { algorithm?: Algorithm; + expires?: Date; hmacKey: string; maxnumber?: number; maxNumber?: number; number?: number; + params?: Record; salt?: string; saltLength?: number; } diff --git a/deno_dist/README.md b/deno_dist/README.md index 2162866..08d892e 100644 --- a/deno_dist/README.md +++ b/deno_dist/README.md @@ -4,11 +4,12 @@ ALTCHA JS Library is a lightweight, zero-dependency library designed for creatin ## Compatibility -This library utilizes [Web Crypto](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto) and is intended for server-side use. +This library utilizes [Web Crypto](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto). - Node.js 16+ - Bun 1+ - Deno 1+ +- All modern browsers ## Usage @@ -37,15 +38,27 @@ Parameters: - `options: ChallengeOptions`: - `algorithm?: string`: Algorithm to use (`SHA-1`, `SHA-256`, `SHA-512`, default: `SHA-256`). + - `expires?: Date`: Optional `expires` time (as `Date` set into the future date). - `hmacKey: string` (required): Signature HMAC key. - - `maxNumber?: number` Optional maximum number for the random number generator (defaults to 1,000,000). + - `maxnumber?: number`: Optional maximum number for the random number generator (defaults to 1,000,000). - `number?: number`: Optional number to use. If not provided, a random number will be generated. + - `params?: Record`: Optional parameters to be added to the salt as URL-encoded query string. Use `extractParams()` to read them. - `salt?: string`: Optional salt string. If not provided, a random salt will be generated. - - `saltLength?: number` Optional maximum lenght of the random salt (in bytes, defaults to 12). + - `saltLength?: number`: Optional maximum lenght of the random salt (in bytes, defaults to 12). Returns: `Promise` -### `verifySolution(payload, hmacKey)` +### `extractParams(payload)` + +Extracts optional parameters from the challenge or payload. + +Parameters: + +- `payload: string | Payload | Challenge` + +Returns: `Record` + +### `verifySolution(payload, hmacKey, checkExpires = true)` Verifies an ALTCHA solution. The payload can be a Base64-encoded JSON payload (as submitted by the widget) or an object. @@ -53,6 +66,7 @@ Parameters: - `payload: string | Payload` - `hmacKey: string` +- `checkExpires: boolean = true`: Whether to perform a check on the optional `expires` parameter. Will return `false` if challenge expired. Returns: `Promise` @@ -65,7 +79,7 @@ Parameters: - `challenge: string` (required): The challenge hash. - `salt: string` (required): The challenge salt. - `algorithm?: string`: Optional algorithm (default: `SHA-256`). -- `max?: string`: Optional `maxnumber` to iterate to (default: 1e6). +- `maxnumber?: string`: Optional `maxnumber` to iterate to (default: 1e6). - `start?: string`: Optional starting number (default: 0). Returns: `{ controller: AbortController, promise: Promise }` @@ -92,7 +106,7 @@ Parameters: - `challenge: string` (required): The challenge hash. - `salt: string` (required): The challenge salt. - `algorithm?: string`: Optional algorithm (default: `SHA-256`). -- `max?: string`: Optional `maxnumber` to iterate to (default: 1e6). +- `maxnumber?: string`: Optional `maxnumber` to iterate to (default: 1e6). - `start?: string`: Optional starting number (default: 0). Returns: `Promise` @@ -114,16 +128,18 @@ const solution = await solveChallengeWorkers( ``` > solveChallenge() -- n = 1,000............................... 317 ops/s ±2.63% -- n = 10,000.............................. 32 ops/s ±1.88% -- n = 100,000............................. 3 ops/s ±0.34% -- n = 500,000............................. 0 ops/s ±0.32% +- n = 1,000............................... 312 ops/s ±2.90% +- n = 10,000.............................. 31 ops/s ±1.50% +- n = 50,000.............................. 6 ops/s ±0.82% +- n = 100,000............................. 3 ops/s ±0.37% +- n = 500,000............................. 0 ops/s ±0.31% > solveChallengeWorkers() (8 workers) -- n = 1,000............................... 66 ops/s ±3.44% -- n = 10,000.............................. 31 ops/s ±4.28% -- n = 100,000............................. 7 ops/s ±4.40% -- n = 500,000............................. 1 ops/s ±2.49% +- n = 1,000............................... 62 ops/s ±3.99% +- n = 10,000.............................. 31 ops/s ±6.83% +- n = 50,000.............................. 11 ops/s ±4.00% +- n = 100,000............................. 7 ops/s ±2.32% +- n = 500,000............................. 1 ops/s ±1.89% ``` Run with Bun on MacBook Pro M3-Pro. See [/benchmark](/benchmark/) folder for more details. diff --git a/deno_dist/index.ts b/deno_dist/index.ts index 91f88e1..b085d5c 100644 --- a/deno_dist/index.ts +++ b/deno_dist/index.ts @@ -27,7 +27,14 @@ export async function createChallenge( const maxnumber = options.maxnumber || options.maxNumber || DEFAULT_MAX_NUMBER; const saltLength = options.saltLength || DEFAULT_SALT_LEN; - const salt = options.salt || ab2hex(randomBytes(saltLength)); + const params = new URLSearchParams(options.params); + if (options.expires) { + params.set('expires', String(Math.floor(options.expires.getTime() / 1000))); + } + let salt = options.salt || ab2hex(randomBytes(saltLength)); + if (params.size) { + salt = salt + '?' + params.toString(); + } const number = options.number === void 0 ? randomInt(maxnumber) : options.number; const challenge = await hashHex(algorithm, salt + number); @@ -40,13 +47,29 @@ export async function createChallenge( }; } +export function extractParams(payload: string | Payload | Challenge) { + if (typeof payload === 'string') { + payload = JSON.parse(atob(payload)) as Payload; + } + return Object.fromEntries(new URLSearchParams(payload.salt.split('?')?.[1] || '')); +} + export async function verifySolution( payload: string | Payload, - hmacKey: string + hmacKey: string, + checkExpires: boolean = true ) { if (typeof payload === 'string') { payload = JSON.parse(atob(payload)) as Payload; } + const params = extractParams(payload); + const expires = params.expires || params.expire; + if (checkExpires && expires) { + const date = new Date(parseInt(expires, 10) * 1000); + if (!isNaN(date.getTime()) && date.getTime() < Date.now()) { + return false; + } + } const check = await createChallenge({ algorithm: payload.algorithm, hmacKey, @@ -107,30 +130,24 @@ export function solveChallenge( start: number = 0 ): { promise: Promise; controller: AbortController } { const controller = new AbortController(); - const promise = new Promise((resolve, reject) => { - const startTime = Date.now(); - const next = (n: number) => { - if (controller.signal.aborted || n > max) { - resolve(null); - } else { - hashHex(algorithm as Algorithm, salt + n) - .then((t) => { - if (t === challenge) { - resolve({ - number: n, - took: Date.now() - startTime, - }); - } else { - next(n + 1); - } - }) - .catch(reject); + const startTime = Date.now(); + const fn = async () => { + for (let n = start; n <= max; n += 1) { + if (controller.signal.aborted) { + return null; } - }; - next(start); - }) as Promise; + const t = await hashHex(algorithm as Algorithm, salt + n); + if (t === challenge) { + return { + number: n, + took: Date.now() - startTime, + }; + } + } + return null; + } return { - promise, + promise: fn(), controller, }; } @@ -198,6 +215,7 @@ export async function solveChallengeWorkers( export default { createChallenge, + extractParams, solveChallenge, solveChallengeWorkers, verifyServerSignature, diff --git a/deno_dist/types.ts b/deno_dist/types.ts index 53046d5..1961355 100644 --- a/deno_dist/types.ts +++ b/deno_dist/types.ts @@ -10,10 +10,12 @@ export interface Challenge { export interface ChallengeOptions { algorithm?: Algorithm; + expires?: Date; hmacKey: string; maxnumber?: number; maxNumber?: number; number?: number; + params?: Record; salt?: string; saltLength?: number; } diff --git a/dist/index.d.ts b/dist/index.d.ts index 1fd3d83..3e8c9bc 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -1,6 +1,9 @@ import type { Challenge, ChallengeOptions, Payload, ServerSignaturePayload, ServerSignatureVerificationData, Solution } from './types.js'; export declare function createChallenge(options: ChallengeOptions): Promise; -export declare function verifySolution(payload: string | Payload, hmacKey: string): Promise; +export declare function extractParams(payload: string | Payload | Challenge): { + [k: string]: string; +}; +export declare function verifySolution(payload: string | Payload, hmacKey: string, checkExpires?: boolean): Promise; export declare function verifyServerSignature(payload: string | ServerSignaturePayload, hmacKey: string): Promise<{ verificationData: ServerSignatureVerificationData | null; verified: boolean | null; @@ -12,6 +15,7 @@ export declare function solveChallenge(challenge: string, salt: string, algorith export declare function solveChallengeWorkers(workerScript: string | URL | (() => Worker), concurrency: number, challenge: string, salt: string, algorithm?: string, max?: number, startNumber?: number): Promise; declare const _default: { createChallenge: typeof createChallenge; + extractParams: typeof extractParams; solveChallenge: typeof solveChallenge; solveChallengeWorkers: typeof solveChallengeWorkers; verifyServerSignature: typeof verifyServerSignature; diff --git a/dist/index.js b/dist/index.js index 460af2e..fc05a61 100644 --- a/dist/index.js +++ b/dist/index.js @@ -6,7 +6,14 @@ export async function createChallenge(options) { const algorithm = options.algorithm || DEFAULT_ALG; const maxnumber = options.maxnumber || options.maxNumber || DEFAULT_MAX_NUMBER; const saltLength = options.saltLength || DEFAULT_SALT_LEN; - const salt = options.salt || ab2hex(randomBytes(saltLength)); + const params = new URLSearchParams(options.params); + if (options.expires) { + params.set('expires', String(Math.floor(options.expires.getTime() / 1000))); + } + let salt = options.salt || ab2hex(randomBytes(saltLength)); + if (params.size) { + salt = salt + '?' + params.toString(); + } const number = options.number === void 0 ? randomInt(maxnumber) : options.number; const challenge = await hashHex(algorithm, salt + number); return { @@ -17,10 +24,24 @@ export async function createChallenge(options) { signature: await hmacHex(algorithm, challenge, options.hmacKey), }; } -export async function verifySolution(payload, hmacKey) { +export function extractParams(payload) { if (typeof payload === 'string') { payload = JSON.parse(atob(payload)); } + return Object.fromEntries(new URLSearchParams(payload.salt.split('?')?.[1] || '')); +} +export async function verifySolution(payload, hmacKey, checkExpires = true) { + if (typeof payload === 'string') { + payload = JSON.parse(atob(payload)); + } + const params = extractParams(payload); + const expires = params.expires || params.expire; + if (checkExpires && expires) { + const date = new Date(parseInt(expires, 10) * 1000); + if (!isNaN(date.getTime()) && date.getTime() < Date.now()) { + return false; + } + } const check = await createChallenge({ algorithm: payload.algorithm, hmacKey, @@ -64,32 +85,24 @@ export async function verifyServerSignature(payload, hmacKey) { } export function solveChallenge(challenge, salt, algorithm = 'SHA-256', max = 1e6, start = 0) { const controller = new AbortController(); - const promise = new Promise((resolve, reject) => { - const startTime = Date.now(); - const next = (n) => { - if (controller.signal.aborted || n > max) { - resolve(null); + const startTime = Date.now(); + const fn = async () => { + for (let n = start; n <= max; n += 1) { + if (controller.signal.aborted) { + return null; } - else { - hashHex(algorithm, salt + n) - .then((t) => { - if (t === challenge) { - resolve({ - number: n, - took: Date.now() - startTime, - }); - } - else { - next(n + 1); - } - }) - .catch(reject); + const t = await hashHex(algorithm, salt + n); + if (t === challenge) { + return { + number: n, + took: Date.now() - startTime, + }; } - }; - next(start); - }); + } + return null; + }; return { - promise, + promise: fn(), controller, }; } @@ -144,6 +157,7 @@ export async function solveChallengeWorkers(workerScript, concurrency, challenge } export default { createChallenge, + extractParams, solveChallenge, solveChallengeWorkers, verifyServerSignature, diff --git a/dist/types.d.ts b/dist/types.d.ts index bf0a2c5..29259a1 100644 --- a/dist/types.d.ts +++ b/dist/types.d.ts @@ -8,10 +8,12 @@ export interface Challenge { } export interface ChallengeOptions { algorithm?: Algorithm; + expires?: Date; hmacKey: string; maxnumber?: number; maxNumber?: number; number?: number; + params?: Record; salt?: string; saltLength?: number; } diff --git a/lib/index.ts b/lib/index.ts index 4ef9053..b4d696f 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -27,7 +27,14 @@ export async function createChallenge( const maxnumber = options.maxnumber || options.maxNumber || DEFAULT_MAX_NUMBER; const saltLength = options.saltLength || DEFAULT_SALT_LEN; - const salt = options.salt || ab2hex(randomBytes(saltLength)); + const params = new URLSearchParams(options.params); + if (options.expires) { + params.set('expires', String(Math.floor(options.expires.getTime() / 1000))); + } + let salt = options.salt || ab2hex(randomBytes(saltLength)); + if (params.size) { + salt = salt + '?' + params.toString(); + } const number = options.number === void 0 ? randomInt(maxnumber) : options.number; const challenge = await hashHex(algorithm, salt + number); @@ -40,13 +47,29 @@ export async function createChallenge( }; } +export function extractParams(payload: string | Payload | Challenge) { + if (typeof payload === 'string') { + payload = JSON.parse(atob(payload)) as Payload; + } + return Object.fromEntries(new URLSearchParams(payload.salt.split('?')?.[1] || '')); +} + export async function verifySolution( payload: string | Payload, - hmacKey: string + hmacKey: string, + checkExpires: boolean = true ) { if (typeof payload === 'string') { payload = JSON.parse(atob(payload)) as Payload; } + const params = extractParams(payload); + const expires = params.expires || params.expire; + if (checkExpires && expires) { + const date = new Date(parseInt(expires, 10) * 1000); + if (!isNaN(date.getTime()) && date.getTime() < Date.now()) { + return false; + } + } const check = await createChallenge({ algorithm: payload.algorithm, hmacKey, @@ -107,30 +130,24 @@ export function solveChallenge( start: number = 0 ): { promise: Promise; controller: AbortController } { const controller = new AbortController(); - const promise = new Promise((resolve, reject) => { - const startTime = Date.now(); - const next = (n: number) => { - if (controller.signal.aborted || n > max) { - resolve(null); - } else { - hashHex(algorithm as Algorithm, salt + n) - .then((t) => { - if (t === challenge) { - resolve({ - number: n, - took: Date.now() - startTime, - }); - } else { - next(n + 1); - } - }) - .catch(reject); + const startTime = Date.now(); + const fn = async () => { + for (let n = start; n <= max; n += 1) { + if (controller.signal.aborted) { + return null; } - }; - next(start); - }) as Promise; + const t = await hashHex(algorithm as Algorithm, salt + n); + if (t === challenge) { + return { + number: n, + took: Date.now() - startTime, + }; + } + } + return null; + } return { - promise, + promise: fn(), controller, }; } @@ -198,6 +215,7 @@ export async function solveChallengeWorkers( export default { createChallenge, + extractParams, solveChallenge, solveChallengeWorkers, verifyServerSignature, diff --git a/lib/types.ts b/lib/types.ts index 53046d5..1961355 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -10,10 +10,12 @@ export interface Challenge { export interface ChallengeOptions { algorithm?: Algorithm; + expires?: Date; hmacKey: string; maxnumber?: number; maxNumber?: number; number?: number; + params?: Record; salt?: string; saltLength?: number; } diff --git a/package.json b/package.json index bafa840..ef46eea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "altcha-lib", - "version": "0.2.0", + "version": "0.2.1", "description": "A library for creating and verifying ALTCHA challenges for Node.js, Bun and Deno.", "author": "Daniel Regeci", "license": "MIT", diff --git a/tests/challenge.test.ts b/tests/challenge.test.ts index 9879a5a..6fdf7e7 100644 --- a/tests/challenge.test.ts +++ b/tests/challenge.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { createChallenge, + extractParams, solveChallenge, solveChallengeWorkers, verifySolution, @@ -60,6 +61,70 @@ describe('challenge', () => { expect(challenge.challenge.length).toEqual(128); expect(challenge.signature.length).toEqual(128); }); + + it('should return a new challenge with expires param', async () => { + const expires = new Date(Date.now() + 3600000); + const challenge = await createChallenge({ + algorithm: 'SHA-256', + expires, + hmacKey, + }); + expect(challenge).toEqual({ + algorithm: 'SHA-256', + challenge: expect.any(String), + maxnumber: expect.any(Number), + salt: expect.any(String), + signature: expect.any(String), + } satisfies Challenge); + expect(challenge.salt.length).toBeGreaterThan(24); + expect(challenge.salt.includes('?expires=')).toBeTruthy(); + expect(challenge.challenge.length).toEqual(64); + expect(challenge.signature.length).toEqual(64); + }); + + it('should return a new challenge with custom params', async () => { + const challenge = await createChallenge({ + algorithm: 'SHA-256', + hmacKey, + params: { + abc: '123', + xyz: '000' + }, + }); + expect(challenge).toEqual({ + algorithm: 'SHA-256', + challenge: expect.any(String), + maxnumber: expect.any(Number), + salt: expect.any(String), + signature: expect.any(String), + } satisfies Challenge); + expect(challenge.salt.length).toBeGreaterThan(24); + expect(challenge.salt.endsWith('?abc=123&xyz=000')).toBeTruthy(); + expect(challenge.challenge.length).toEqual(64); + expect(challenge.signature.length).toEqual(64); + }); + }); + + describe('extractParams', () => { + it('should extract custom params from payload', async () => { + const number = 100; + const challenge = await createChallenge({ + number, + hmacKey, + params: { + abc: '123', + xyz: '000' + }, + }); + const params = extractParams({ + ...challenge, + number, + }); + expect(params).toEqual({ + abc: '123', + xyz: '000' + }); + }); }); describe('verifySolution()', () => { @@ -82,6 +147,26 @@ describe('challenge', () => { expect(ok).toEqual(true); }); + it('should return true with expires in the future', async () => { + const number = 100; + const challenge = await createChallenge({ + expires: new Date(Date.now() + 3600000), + number, + hmacKey, + }); + const ok = await verifySolution( + { + algorithm: challenge.algorithm, + challenge: challenge.challenge, + number, + salt: challenge.salt, + signature: challenge.signature, + }, + hmacKey + ); + expect(ok).toEqual(true); + }); + it('should return false if number is incorrect', async () => { const challenge = await createChallenge({ number: 100, @@ -175,6 +260,26 @@ describe('challenge', () => { ); expect(ok).toEqual(false); }); + + it('should return false if the challenge expired', async () => { + const number = 100; + const challenge = await createChallenge({ + expires: new Date(Date.now() - 3600000), + number, + hmacKey, + }); + const ok = await verifySolution( + { + algorithm: challenge.algorithm, + challenge: challenge.challenge, + number, + salt: challenge.salt, + signature: challenge.signature, + }, + hmacKey + ); + expect(ok).toEqual(false); + }); }); describe('solveChallenge', () => {