diff --git a/README.md b/README.md index 48c687a..2162866 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,17 @@ Parameters: Returns: `{ controller: AbortController, promise: Promise }` +### `verifyServerSignature(payload, hmacKey)` + +Verifies the server signature returned by the API. The payload can be a Base64-encoded JSON payload or an object. + +Parameters: + +- `payload: string | ServerSignaturePayload` +- `hmacKey: string` + +Returns: `Promise<{ verificationData: ServerSignatureVerificationData | null, verified: boolean }>` + ### `solveChallengeWorkers(workerScript, concurrency, challenge, salt, algorithm?, max?, start?)` Finds a solution to the given challenge with [Web Workers](https://developer.mozilla.org/en-US/docs/Web/API/Worker/Worker) running concurrently. diff --git a/cjs/dist/helpers.d.ts b/cjs/dist/helpers.d.ts index 58941f6..089c7fa 100644 --- a/cjs/dist/helpers.d.ts +++ b/cjs/dist/helpers.d.ts @@ -2,7 +2,9 @@ import './crypto.js'; import type { Algorithm } from './types.js'; export declare const encoder: TextEncoder; export declare function ab2hex(ab: ArrayBuffer | Uint8Array): string; -export declare function hash(algorithm: Algorithm, str: string): Promise; -export declare function hmac(algorithm: Algorithm, str: string, secret: string): Promise; +export declare function hash(algorithm: Algorithm, data: ArrayBuffer | string): Promise; +export declare function hashHex(algorithm: Algorithm, data: ArrayBuffer | string): Promise; +export declare function hmac(algorithm: Algorithm, data: ArrayBuffer | string, secret: string): Promise; +export declare function hmacHex(algorithm: Algorithm, data: ArrayBuffer | string, secret: string): Promise; export declare function randomBytes(length: number): Uint8Array; export declare function randomInt(max: number): number; diff --git a/cjs/dist/helpers.js b/cjs/dist/helpers.js index 6d9a499..f4f8885 100644 --- a/cjs/dist/helpers.js +++ b/cjs/dist/helpers.js @@ -1,6 +1,6 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.randomInt = exports.randomBytes = exports.hmac = exports.hash = exports.ab2hex = exports.encoder = void 0; +exports.randomInt = exports.randomBytes = exports.hmacHex = exports.hmac = exports.hashHex = exports.hash = exports.ab2hex = exports.encoder = void 0; // @denoify-line-ignore require("./crypto.js"); exports.encoder = new TextEncoder(); @@ -10,18 +10,26 @@ function ab2hex(ab) { .join(''); } exports.ab2hex = ab2hex; -async function hash(algorithm, str) { - return ab2hex(await crypto.subtle.digest(algorithm.toUpperCase(), exports.encoder.encode(str))); +async function hash(algorithm, data) { + return crypto.subtle.digest(algorithm.toUpperCase(), typeof data === 'string' ? exports.encoder.encode(data) : new Uint8Array(data)); } exports.hash = hash; -async function hmac(algorithm, str, secret) { +async function hashHex(algorithm, data) { + return ab2hex(await hash(algorithm, data)); +} +exports.hashHex = hashHex; +async function hmac(algorithm, data, secret) { const key = await crypto.subtle.importKey('raw', exports.encoder.encode(secret), { name: 'HMAC', hash: algorithm, }, false, ['sign', 'verify']); - return ab2hex(await crypto.subtle.sign('HMAC', key, exports.encoder.encode(str))); + return crypto.subtle.sign('HMAC', key, typeof data === 'string' ? exports.encoder.encode(data) : new Uint8Array(data)); } exports.hmac = hmac; +async function hmacHex(algorithm, data, secret) { + return ab2hex(await hmac(algorithm, data, secret)); +} +exports.hmacHex = hmacHex; function randomBytes(length) { const ab = new Uint8Array(length); crypto.getRandomValues(ab); diff --git a/cjs/dist/index.d.ts b/cjs/dist/index.d.ts index 3f6313a..1fd3d83 100644 --- a/cjs/dist/index.d.ts +++ b/cjs/dist/index.d.ts @@ -1,6 +1,10 @@ -import type { Challenge, ChallengeOptions, Payload, Solution } from './types.js'; +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 verifyServerSignature(payload: string | ServerSignaturePayload, hmacKey: string): Promise<{ + verificationData: ServerSignatureVerificationData | null; + verified: boolean | null; +}>; export declare function solveChallenge(challenge: string, salt: string, algorithm?: string, max?: number, start?: number): { promise: Promise; controller: AbortController; @@ -8,8 +12,9 @@ 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; - verifySolution: typeof verifySolution; solveChallenge: typeof solveChallenge; solveChallengeWorkers: typeof solveChallengeWorkers; + verifyServerSignature: typeof verifyServerSignature; + verifySolution: typeof verifySolution; }; export default _default; diff --git a/cjs/dist/index.js b/cjs/dist/index.js index b8544dd..2564fc8 100644 --- a/cjs/dist/index.js +++ b/cjs/dist/index.js @@ -1,23 +1,23 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.solveChallengeWorkers = exports.solveChallenge = exports.verifySolution = exports.createChallenge = void 0; +exports.solveChallengeWorkers = exports.solveChallenge = exports.verifyServerSignature = exports.verifySolution = exports.createChallenge = void 0; const helpers_js_1 = require("./helpers.js"); const DEFAULT_MAX_NUMBER = 1e6; const DEFAULT_SALT_LEN = 12; const DEFAULT_ALG = 'SHA-256'; async function createChallenge(options) { const algorithm = options.algorithm || DEFAULT_ALG; - const max = options.maxNumber || DEFAULT_MAX_NUMBER; + 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 number = options.number === void 0 ? (0, helpers_js_1.randomInt)(max) : options.number; - const challenge = await (0, helpers_js_1.hash)(algorithm, salt + number); + 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 { algorithm, challenge, - max, + maxnumber, salt, - signature: await (0, helpers_js_1.hmac)(algorithm, challenge, options.hmacKey), + signature: await (0, helpers_js_1.hmacHex)(algorithm, challenge, options.hmacKey), }; } exports.createChallenge = createChallenge; @@ -35,6 +35,39 @@ async function verifySolution(payload, hmacKey) { check.signature === payload.signature); } exports.verifySolution = verifySolution; +async function verifyServerSignature(payload, hmacKey) { + if (typeof payload === 'string') { + payload = JSON.parse(atob(payload)); + } + const signature = await (0, helpers_js_1.hmacHex)(payload.algorithm, await (0, helpers_js_1.hash)(payload.algorithm, payload.verificationData), hmacKey); + let verificationData = null; + try { + const params = new URLSearchParams(payload.verificationData); + verificationData = { + ...Object.fromEntries(params), + expire: parseInt(params.get('expire') || '0', 10), + fields: params.get('fields')?.split(','), + reasons: params.get('reasons')?.split(','), + score: params.get('score') + ? parseFloat(params.get('score') || '0') + : void 0, + time: parseInt(params.get('time') || '0', 10), + verified: params.get('verified') === 'true', + }; + } + catch { + // noop + } + return { + verificationData, + verified: payload.verified === true && + verificationData && + verificationData.verified === true && + verificationData.expire > Math.floor(Date.now() / 1000) && + payload.signature === signature, + }; +} +exports.verifyServerSignature = verifyServerSignature; function solveChallenge(challenge, salt, algorithm = 'SHA-256', max = 1e6, start = 0) { const controller = new AbortController(); const promise = new Promise((resolve, reject) => { @@ -44,7 +77,7 @@ function solveChallenge(challenge, salt, algorithm = 'SHA-256', max = 1e6, start resolve(null); } else { - hashChallenge(salt, n, algorithm) + (0, helpers_js_1.hashHex)(algorithm, salt + n) .then((t) => { if (t === challenge) { resolve({ @@ -117,12 +150,10 @@ async function solveChallengeWorkers(workerScript, concurrency, challenge, salt, return solutions.find((solution) => !!solution) || null; } exports.solveChallengeWorkers = solveChallengeWorkers; -async function hashChallenge(salt, num, algorithm) { - return (0, helpers_js_1.ab2hex)(await crypto.subtle.digest(algorithm.toUpperCase(), helpers_js_1.encoder.encode(salt + num))); -} exports.default = { createChallenge, - verifySolution, solveChallenge, solveChallengeWorkers, + verifyServerSignature, + verifySolution, }; diff --git a/cjs/dist/types.d.ts b/cjs/dist/types.d.ts index 6603a73..bf0a2c5 100644 --- a/cjs/dist/types.d.ts +++ b/cjs/dist/types.d.ts @@ -2,13 +2,14 @@ export type Algorithm = 'SHA-1' | 'SHA-256' | 'SHA-512'; export interface Challenge { algorithm: Algorithm; challenge: string; - max?: number; + maxnumber?: number; salt: string; signature: string; } export interface ChallengeOptions { algorithm?: Algorithm; hmacKey: string; + maxnumber?: number; maxNumber?: number; number?: number; salt?: string; @@ -21,6 +22,23 @@ export interface Payload { salt: string; signature: string; } +export interface ServerSignaturePayload { + algorithm: Algorithm; + signature: string; + verificationData: string; + verified: boolean; +} +export interface ServerSignatureVerificationData { + classification?: string; + email?: string; + expire: number; + fields?: string[]; + fieldsHash?: string; + reasons?: string[]; + score?: number; + time: number; + verified: boolean; +} export interface Solution { number: number; took: number; diff --git a/deno_dist/README.md b/deno_dist/README.md index 48c687a..2162866 100644 --- a/deno_dist/README.md +++ b/deno_dist/README.md @@ -70,6 +70,17 @@ Parameters: Returns: `{ controller: AbortController, promise: Promise }` +### `verifyServerSignature(payload, hmacKey)` + +Verifies the server signature returned by the API. The payload can be a Base64-encoded JSON payload or an object. + +Parameters: + +- `payload: string | ServerSignaturePayload` +- `hmacKey: string` + +Returns: `Promise<{ verificationData: ServerSignatureVerificationData | null, verified: boolean }>` + ### `solveChallengeWorkers(workerScript, concurrency, challenge, salt, algorithm?, max?, start?)` Finds a solution to the given challenge with [Web Workers](https://developer.mozilla.org/en-US/docs/Web/API/Worker/Worker) running concurrently. diff --git a/deno_dist/helpers.ts b/deno_dist/helpers.ts index 422920a..fd8846a 100644 --- a/deno_dist/helpers.ts +++ b/deno_dist/helpers.ts @@ -8,13 +8,25 @@ export function ab2hex(ab: ArrayBuffer | Uint8Array) { .join(''); } -export async function hash(algorithm: Algorithm, str: string) { - return ab2hex( - await crypto.subtle.digest(algorithm.toUpperCase(), encoder.encode(str)) +export async function hash(algorithm: Algorithm, data: ArrayBuffer | string) { + return crypto.subtle.digest( + algorithm.toUpperCase(), + typeof data === 'string' ? encoder.encode(data) : new Uint8Array(data) ); } -export async function hmac(algorithm: Algorithm, str: string, secret: string) { +export async function hashHex( + algorithm: Algorithm, + data: ArrayBuffer | string +) { + return ab2hex(await hash(algorithm, data)); +} + +export async function hmac( + algorithm: Algorithm, + data: ArrayBuffer | string, + secret: string +) { const key = await crypto.subtle.importKey( 'raw', encoder.encode(secret), @@ -25,7 +37,19 @@ export async function hmac(algorithm: Algorithm, str: string, secret: string) { false, ['sign', 'verify'] ); - return ab2hex(await crypto.subtle.sign('HMAC', key, encoder.encode(str))); + return crypto.subtle.sign( + 'HMAC', + key, + typeof data === 'string' ? encoder.encode(data) : new Uint8Array(data) + ); +} + +export async function hmacHex( + algorithm: Algorithm, + data: ArrayBuffer | string, + secret: string +) { + return ab2hex(await hmac(algorithm, data, secret)); } export function randomBytes(length: number) { diff --git a/deno_dist/index.ts b/deno_dist/index.ts index 0e4fdde..91f88e1 100644 --- a/deno_dist/index.ts +++ b/deno_dist/index.ts @@ -1,8 +1,8 @@ import { ab2hex, - encoder, hash, - hmac, + hashHex, + hmacHex, randomBytes, randomInt, } from './helpers.ts'; @@ -11,6 +11,8 @@ import type { Challenge, ChallengeOptions, Payload, + ServerSignaturePayload, + ServerSignatureVerificationData, Solution, } from './types.ts'; @@ -22,17 +24,19 @@ export async function createChallenge( options: ChallengeOptions ): Promise { const algorithm = options.algorithm || DEFAULT_ALG; - const max = options.maxNumber || DEFAULT_MAX_NUMBER; + const maxnumber = + options.maxnumber || options.maxNumber || DEFAULT_MAX_NUMBER; const saltLength = options.saltLength || DEFAULT_SALT_LEN; const salt = options.salt || ab2hex(randomBytes(saltLength)); - const number = options.number === void 0 ? randomInt(max) : options.number; - const challenge = await hash(algorithm, salt + number); + const number = + options.number === void 0 ? randomInt(maxnumber) : options.number; + const challenge = await hashHex(algorithm, salt + number); return { algorithm, challenge, - max, + maxnumber, salt, - signature: await hmac(algorithm, challenge, options.hmacKey), + signature: await hmacHex(algorithm, challenge, options.hmacKey), }; } @@ -55,6 +59,46 @@ export async function verifySolution( ); } +export async function verifyServerSignature( + payload: string | ServerSignaturePayload, + hmacKey: string +) { + if (typeof payload === 'string') { + payload = JSON.parse(atob(payload)) as ServerSignaturePayload; + } + const signature = await hmacHex( + payload.algorithm, + await hash(payload.algorithm, payload.verificationData), + hmacKey + ); + let verificationData: ServerSignatureVerificationData | null = null; + try { + const params = new URLSearchParams(payload.verificationData); + verificationData = { + ...Object.fromEntries(params), + expire: parseInt(params.get('expire') || '0', 10), + fields: params.get('fields')?.split(','), + reasons: params.get('reasons')?.split(','), + score: params.get('score') + ? parseFloat(params.get('score') || '0') + : void 0, + time: parseInt(params.get('time') || '0', 10), + verified: params.get('verified') === 'true', + }; + } catch { + // noop + } + return { + verificationData, + verified: + payload.verified === true && + verificationData && + verificationData.verified === true && + verificationData.expire > Math.floor(Date.now() / 1000) && + payload.signature === signature, + }; +} + export function solveChallenge( challenge: string, salt: string, @@ -69,7 +113,7 @@ export function solveChallenge( if (controller.signal.aborted || n > max) { resolve(null); } else { - hashChallenge(salt, n, algorithm) + hashHex(algorithm as Algorithm, salt + n) .then((t) => { if (t === challenge) { resolve({ @@ -152,18 +196,10 @@ export async function solveChallengeWorkers( return solutions.find((solution) => !!solution) || null; } -async function hashChallenge(salt: string, num: number, algorithm: string) { - return ab2hex( - await crypto.subtle.digest( - algorithm.toUpperCase(), - encoder.encode(salt + num) - ) - ); -} - export default { createChallenge, - verifySolution, solveChallenge, solveChallengeWorkers, + verifyServerSignature, + verifySolution, }; diff --git a/deno_dist/types.ts b/deno_dist/types.ts index 909e84e..53046d5 100644 --- a/deno_dist/types.ts +++ b/deno_dist/types.ts @@ -3,7 +3,7 @@ export type Algorithm = 'SHA-1' | 'SHA-256' | 'SHA-512'; export interface Challenge { algorithm: Algorithm; challenge: string; - max?: number; + maxnumber?: number; salt: string; signature: string; } @@ -11,6 +11,7 @@ export interface Challenge { export interface ChallengeOptions { algorithm?: Algorithm; hmacKey: string; + maxnumber?: number; maxNumber?: number; number?: number; salt?: string; @@ -25,6 +26,25 @@ export interface Payload { signature: string; } +export interface ServerSignaturePayload { + algorithm: Algorithm; + signature: string; + verificationData: string; + verified: boolean; +} + +export interface ServerSignatureVerificationData { + classification?: string; + email?: string; + expire: number; + fields?: string[]; + fieldsHash?: string; + reasons?: string[]; + score?: number; + time: number; + verified: boolean; +} + export interface Solution { number: number; took: number; diff --git a/dist/helpers.d.ts b/dist/helpers.d.ts index 58941f6..089c7fa 100644 --- a/dist/helpers.d.ts +++ b/dist/helpers.d.ts @@ -2,7 +2,9 @@ import './crypto.js'; import type { Algorithm } from './types.js'; export declare const encoder: TextEncoder; export declare function ab2hex(ab: ArrayBuffer | Uint8Array): string; -export declare function hash(algorithm: Algorithm, str: string): Promise; -export declare function hmac(algorithm: Algorithm, str: string, secret: string): Promise; +export declare function hash(algorithm: Algorithm, data: ArrayBuffer | string): Promise; +export declare function hashHex(algorithm: Algorithm, data: ArrayBuffer | string): Promise; +export declare function hmac(algorithm: Algorithm, data: ArrayBuffer | string, secret: string): Promise; +export declare function hmacHex(algorithm: Algorithm, data: ArrayBuffer | string, secret: string): Promise; export declare function randomBytes(length: number): Uint8Array; export declare function randomInt(max: number): number; diff --git a/dist/helpers.js b/dist/helpers.js index 0acc591..77d4087 100644 --- a/dist/helpers.js +++ b/dist/helpers.js @@ -6,15 +6,21 @@ export function ab2hex(ab) { .map((x) => x.toString(16).padStart(2, '0')) .join(''); } -export async function hash(algorithm, str) { - return ab2hex(await crypto.subtle.digest(algorithm.toUpperCase(), encoder.encode(str))); +export async function hash(algorithm, data) { + return crypto.subtle.digest(algorithm.toUpperCase(), typeof data === 'string' ? encoder.encode(data) : new Uint8Array(data)); } -export async function hmac(algorithm, str, secret) { +export async function hashHex(algorithm, data) { + return ab2hex(await hash(algorithm, data)); +} +export async function hmac(algorithm, data, secret) { const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: algorithm, }, false, ['sign', 'verify']); - return ab2hex(await crypto.subtle.sign('HMAC', key, encoder.encode(str))); + return crypto.subtle.sign('HMAC', key, typeof data === 'string' ? encoder.encode(data) : new Uint8Array(data)); +} +export async function hmacHex(algorithm, data, secret) { + return ab2hex(await hmac(algorithm, data, secret)); } export function randomBytes(length) { const ab = new Uint8Array(length); diff --git a/dist/index.d.ts b/dist/index.d.ts index 3f6313a..1fd3d83 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -1,6 +1,10 @@ -import type { Challenge, ChallengeOptions, Payload, Solution } from './types.js'; +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 verifyServerSignature(payload: string | ServerSignaturePayload, hmacKey: string): Promise<{ + verificationData: ServerSignatureVerificationData | null; + verified: boolean | null; +}>; export declare function solveChallenge(challenge: string, salt: string, algorithm?: string, max?: number, start?: number): { promise: Promise; controller: AbortController; @@ -8,8 +12,9 @@ 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; - verifySolution: typeof verifySolution; solveChallenge: typeof solveChallenge; solveChallengeWorkers: typeof solveChallengeWorkers; + verifyServerSignature: typeof verifyServerSignature; + verifySolution: typeof verifySolution; }; export default _default; diff --git a/dist/index.js b/dist/index.js index d9667ee..460af2e 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1,20 +1,20 @@ -import { ab2hex, encoder, hash, hmac, randomBytes, randomInt, } from './helpers.js'; +import { ab2hex, hash, hashHex, hmacHex, randomBytes, randomInt, } from './helpers.js'; const DEFAULT_MAX_NUMBER = 1e6; const DEFAULT_SALT_LEN = 12; const DEFAULT_ALG = 'SHA-256'; export async function createChallenge(options) { const algorithm = options.algorithm || DEFAULT_ALG; - const max = options.maxNumber || DEFAULT_MAX_NUMBER; + const maxnumber = options.maxnumber || options.maxNumber || DEFAULT_MAX_NUMBER; const saltLength = options.saltLength || DEFAULT_SALT_LEN; const salt = options.salt || ab2hex(randomBytes(saltLength)); - const number = options.number === void 0 ? randomInt(max) : options.number; - const challenge = await hash(algorithm, salt + number); + const number = options.number === void 0 ? randomInt(maxnumber) : options.number; + const challenge = await hashHex(algorithm, salt + number); return { algorithm, challenge, - max, + maxnumber, salt, - signature: await hmac(algorithm, challenge, options.hmacKey), + signature: await hmacHex(algorithm, challenge, options.hmacKey), }; } export async function verifySolution(payload, hmacKey) { @@ -30,6 +30,38 @@ export async function verifySolution(payload, hmacKey) { return (check.challenge === payload.challenge && check.signature === payload.signature); } +export async function verifyServerSignature(payload, hmacKey) { + if (typeof payload === 'string') { + payload = JSON.parse(atob(payload)); + } + const signature = await hmacHex(payload.algorithm, await hash(payload.algorithm, payload.verificationData), hmacKey); + let verificationData = null; + try { + const params = new URLSearchParams(payload.verificationData); + verificationData = { + ...Object.fromEntries(params), + expire: parseInt(params.get('expire') || '0', 10), + fields: params.get('fields')?.split(','), + reasons: params.get('reasons')?.split(','), + score: params.get('score') + ? parseFloat(params.get('score') || '0') + : void 0, + time: parseInt(params.get('time') || '0', 10), + verified: params.get('verified') === 'true', + }; + } + catch { + // noop + } + return { + verificationData, + verified: payload.verified === true && + verificationData && + verificationData.verified === true && + verificationData.expire > Math.floor(Date.now() / 1000) && + payload.signature === signature, + }; +} export function solveChallenge(challenge, salt, algorithm = 'SHA-256', max = 1e6, start = 0) { const controller = new AbortController(); const promise = new Promise((resolve, reject) => { @@ -39,7 +71,7 @@ export function solveChallenge(challenge, salt, algorithm = 'SHA-256', max = 1e6 resolve(null); } else { - hashChallenge(salt, n, algorithm) + hashHex(algorithm, salt + n) .then((t) => { if (t === challenge) { resolve({ @@ -110,12 +142,10 @@ export async function solveChallengeWorkers(workerScript, concurrency, challenge } return solutions.find((solution) => !!solution) || null; } -async function hashChallenge(salt, num, algorithm) { - return ab2hex(await crypto.subtle.digest(algorithm.toUpperCase(), encoder.encode(salt + num))); -} export default { createChallenge, - verifySolution, solveChallenge, solveChallengeWorkers, + verifyServerSignature, + verifySolution, }; diff --git a/dist/types.d.ts b/dist/types.d.ts index 6603a73..bf0a2c5 100644 --- a/dist/types.d.ts +++ b/dist/types.d.ts @@ -2,13 +2,14 @@ export type Algorithm = 'SHA-1' | 'SHA-256' | 'SHA-512'; export interface Challenge { algorithm: Algorithm; challenge: string; - max?: number; + maxnumber?: number; salt: string; signature: string; } export interface ChallengeOptions { algorithm?: Algorithm; hmacKey: string; + maxnumber?: number; maxNumber?: number; number?: number; salt?: string; @@ -21,6 +22,23 @@ export interface Payload { salt: string; signature: string; } +export interface ServerSignaturePayload { + algorithm: Algorithm; + signature: string; + verificationData: string; + verified: boolean; +} +export interface ServerSignatureVerificationData { + classification?: string; + email?: string; + expire: number; + fields?: string[]; + fieldsHash?: string; + reasons?: string[]; + score?: number; + time: number; + verified: boolean; +} export interface Solution { number: number; took: number; diff --git a/lib/helpers.ts b/lib/helpers.ts index 3fba815..8093bad 100644 --- a/lib/helpers.ts +++ b/lib/helpers.ts @@ -10,13 +10,25 @@ export function ab2hex(ab: ArrayBuffer | Uint8Array) { .join(''); } -export async function hash(algorithm: Algorithm, str: string) { - return ab2hex( - await crypto.subtle.digest(algorithm.toUpperCase(), encoder.encode(str)) +export async function hash(algorithm: Algorithm, data: ArrayBuffer | string) { + return crypto.subtle.digest( + algorithm.toUpperCase(), + typeof data === 'string' ? encoder.encode(data) : new Uint8Array(data) ); } -export async function hmac(algorithm: Algorithm, str: string, secret: string) { +export async function hashHex( + algorithm: Algorithm, + data: ArrayBuffer | string +) { + return ab2hex(await hash(algorithm, data)); +} + +export async function hmac( + algorithm: Algorithm, + data: ArrayBuffer | string, + secret: string +) { const key = await crypto.subtle.importKey( 'raw', encoder.encode(secret), @@ -27,7 +39,19 @@ export async function hmac(algorithm: Algorithm, str: string, secret: string) { false, ['sign', 'verify'] ); - return ab2hex(await crypto.subtle.sign('HMAC', key, encoder.encode(str))); + return crypto.subtle.sign( + 'HMAC', + key, + typeof data === 'string' ? encoder.encode(data) : new Uint8Array(data) + ); +} + +export async function hmacHex( + algorithm: Algorithm, + data: ArrayBuffer | string, + secret: string +) { + return ab2hex(await hmac(algorithm, data, secret)); } export function randomBytes(length: number) { diff --git a/lib/index.ts b/lib/index.ts index 91d5bc3..4ef9053 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,8 +1,8 @@ import { ab2hex, - encoder, hash, - hmac, + hashHex, + hmacHex, randomBytes, randomInt, } from './helpers.js'; @@ -11,6 +11,8 @@ import type { Challenge, ChallengeOptions, Payload, + ServerSignaturePayload, + ServerSignatureVerificationData, Solution, } from './types.js'; @@ -22,17 +24,19 @@ export async function createChallenge( options: ChallengeOptions ): Promise { const algorithm = options.algorithm || DEFAULT_ALG; - const max = options.maxNumber || DEFAULT_MAX_NUMBER; + const maxnumber = + options.maxnumber || options.maxNumber || DEFAULT_MAX_NUMBER; const saltLength = options.saltLength || DEFAULT_SALT_LEN; const salt = options.salt || ab2hex(randomBytes(saltLength)); - const number = options.number === void 0 ? randomInt(max) : options.number; - const challenge = await hash(algorithm, salt + number); + const number = + options.number === void 0 ? randomInt(maxnumber) : options.number; + const challenge = await hashHex(algorithm, salt + number); return { algorithm, challenge, - max, + maxnumber, salt, - signature: await hmac(algorithm, challenge, options.hmacKey), + signature: await hmacHex(algorithm, challenge, options.hmacKey), }; } @@ -55,6 +59,46 @@ export async function verifySolution( ); } +export async function verifyServerSignature( + payload: string | ServerSignaturePayload, + hmacKey: string +) { + if (typeof payload === 'string') { + payload = JSON.parse(atob(payload)) as ServerSignaturePayload; + } + const signature = await hmacHex( + payload.algorithm, + await hash(payload.algorithm, payload.verificationData), + hmacKey + ); + let verificationData: ServerSignatureVerificationData | null = null; + try { + const params = new URLSearchParams(payload.verificationData); + verificationData = { + ...Object.fromEntries(params), + expire: parseInt(params.get('expire') || '0', 10), + fields: params.get('fields')?.split(','), + reasons: params.get('reasons')?.split(','), + score: params.get('score') + ? parseFloat(params.get('score') || '0') + : void 0, + time: parseInt(params.get('time') || '0', 10), + verified: params.get('verified') === 'true', + }; + } catch { + // noop + } + return { + verificationData, + verified: + payload.verified === true && + verificationData && + verificationData.verified === true && + verificationData.expire > Math.floor(Date.now() / 1000) && + payload.signature === signature, + }; +} + export function solveChallenge( challenge: string, salt: string, @@ -69,7 +113,7 @@ export function solveChallenge( if (controller.signal.aborted || n > max) { resolve(null); } else { - hashChallenge(salt, n, algorithm) + hashHex(algorithm as Algorithm, salt + n) .then((t) => { if (t === challenge) { resolve({ @@ -152,18 +196,10 @@ export async function solveChallengeWorkers( return solutions.find((solution) => !!solution) || null; } -async function hashChallenge(salt: string, num: number, algorithm: string) { - return ab2hex( - await crypto.subtle.digest( - algorithm.toUpperCase(), - encoder.encode(salt + num) - ) - ); -} - export default { createChallenge, - verifySolution, solveChallenge, solveChallengeWorkers, + verifyServerSignature, + verifySolution, }; diff --git a/lib/types.ts b/lib/types.ts index 909e84e..53046d5 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -3,7 +3,7 @@ export type Algorithm = 'SHA-1' | 'SHA-256' | 'SHA-512'; export interface Challenge { algorithm: Algorithm; challenge: string; - max?: number; + maxnumber?: number; salt: string; signature: string; } @@ -11,6 +11,7 @@ export interface Challenge { export interface ChallengeOptions { algorithm?: Algorithm; hmacKey: string; + maxnumber?: number; maxNumber?: number; number?: number; salt?: string; @@ -25,6 +26,25 @@ export interface Payload { signature: string; } +export interface ServerSignaturePayload { + algorithm: Algorithm; + signature: string; + verificationData: string; + verified: boolean; +} + +export interface ServerSignatureVerificationData { + classification?: string; + email?: string; + expire: number; + fields?: string[]; + fieldsHash?: string; + reasons?: string[]; + score?: number; + time: number; + verified: boolean; +} + export interface Solution { number: number; took: number; diff --git a/package.json b/package.json index 3ea3699..bafa840 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "altcha-lib", - "version": "0.1.5", + "version": "0.2.0", "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 c149ccb..9879a5a 100644 --- a/tests/challenge.test.ts +++ b/tests/challenge.test.ts @@ -18,7 +18,7 @@ describe('challenge', () => { expect(challenge).toEqual({ algorithm: 'SHA-256', challenge: expect.any(String), - max: expect.any(Number), + maxnumber: expect.any(Number), salt: expect.any(String), signature: expect.any(String), } satisfies Challenge); @@ -35,7 +35,7 @@ describe('challenge', () => { expect(challenge).toEqual({ algorithm: 'SHA-1', challenge: expect.any(String), - max: expect.any(Number), + maxnumber: expect.any(Number), salt: expect.any(String), signature: expect.any(String), } satisfies Challenge); @@ -52,7 +52,7 @@ describe('challenge', () => { expect(challenge).toEqual({ algorithm: 'SHA-512', challenge: expect.any(String), - max: expect.any(Number), + maxnumber: expect.any(Number), salt: expect.any(String), signature: expect.any(String), } satisfies Challenge); @@ -202,9 +202,10 @@ describe('challenge', () => { hmacKey, }); const result = await solveChallengeWorkers( - () => new Worker('./lib/worker.ts', { - type: 'module', - }), + () => + new Worker('./lib/worker.ts', { + type: 'module', + }), 4, challenge.challenge, challenge.salt, diff --git a/tests/deno.ts b/tests/deno.ts index 20247fa..92ef836 100644 --- a/tests/deno.ts +++ b/tests/deno.ts @@ -1,3 +1,7 @@ +/** + * Run: deno test --allow-read tests/deno.ts + */ + import { assertEquals } from 'https://deno.land/std@0.213.0/assert/mod.ts'; import { createChallenge, diff --git a/tests/helpers.test.ts b/tests/helpers.test.ts index 3002cac..b3faf48 100644 --- a/tests/helpers.test.ts +++ b/tests/helpers.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { ab2hex, hash, hmac, randomBytes, randomInt } from '../lib/helpers.js'; +import { + ab2hex, + hashHex, + hmacHex, + randomBytes, + randomInt, +} from '../lib/helpers.js'; const encoder = new TextEncoder(); @@ -12,39 +18,39 @@ describe('helpers', () => { }); }); - describe('hash()', () => { + describe('hashHex()', () => { it('should return SHA-1 hash', async () => { - expect(await hash('SHA-1', 'hello world')).toEqual( + expect(await hashHex('SHA-1', 'hello world')).toEqual( '2aae6c35c94fcfb415dbe95f408b9ce91ee846ed' ); }); it('should return SHA-256 hash', async () => { - expect(await hash('SHA-256', 'hello world')).toEqual( + expect(await hashHex('SHA-256', 'hello world')).toEqual( 'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9' ); }); it('should return SHA-512 hash', async () => { - expect(await hash('SHA-512', 'hello world')).toEqual( + expect(await hashHex('SHA-512', 'hello world')).toEqual( '309ecc489c12d6eb4cc40f50c902f2b4d0ed77ee511a7c7a9bcd3ca86d4cd86f989dd35bc5ff499670da34255b45b0cfd830e81f605dcf7dc5542e93ae9cd76f' ); }); }); - describe('hmac()', () => { + describe('hmacHex()', () => { it('should return HMAC-1', async () => { - expect(await hmac('SHA-1', 'hello world', 'test')).toEqual( + expect(await hmacHex('SHA-1', 'hello world', 'test')).toEqual( '5a09e304f3c60d633ff16735ec931e1116ff21d1' ); }); it('should return HMAC-256', async () => { - expect(await hmac('SHA-256', 'hello world', 'test')).toEqual( + expect(await hmacHex('SHA-256', 'hello world', 'test')).toEqual( 'd1596e0d4280f2bd2d311ce0819f23bde0dc834d8254b92924088de94c38d922' ); }); it('should return HMAC-512', async () => { - expect(await hmac('SHA-512', 'hello world', 'test')).toEqual( + expect(await hmacHex('SHA-512', 'hello world', 'test')).toEqual( '2536d175df94a4638110701d8a0e2cbe56e35f2dcfd167819148cd0f2c8780cb3d3df52b4aea8f929004dd07235ae802f4b5d160a2b8b82e8c2f066289de85a3' ); }); diff --git a/tests/serversignature.test.ts b/tests/serversignature.test.ts new file mode 100644 index 0000000..ceb7f6e --- /dev/null +++ b/tests/serversignature.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { verifyServerSignature } from '../lib/index.js'; +import { hash, hmacHex } from '../lib/helpers.js'; + +describe('server signature', () => { + const hmacKey = 'test key'; + + describe('verifyServerSignature()', () => { + it('shoult return verified', async () => { + const time = Math.floor(Date.now() / 1000); + const verificationData = new URLSearchParams({ + email: 'čžýěžě@sfffd.net', + expire: String(time + 10000), + time: String(time), + verified: String(true), + }).toString(); + const signature = await hmacHex( + 'SHA-256', + await hash('SHA-256', verificationData), + hmacKey + ); + const payload = btoa( + JSON.stringify({ + algorithm: 'SHA-256', + signature, + verificationData, + verified: true, + }) + ); + const result = await verifyServerSignature(payload, hmacKey); + expect(result.verified).toEqual(true); + }); + }); +});