diff --git a/pkgs/server/package.json b/pkgs/server/package.json new file mode 100644 index 0000000..7eb5f7c --- /dev/null +++ b/pkgs/server/package.json @@ -0,0 +1,13 @@ +{ + "name": "@pow-captcha/server", + "version": "0.0.0", + "module": "dist/lib.js", + "types": "dist/lib.d.ts", + "scripts": { + "build": "tsc -p tsconfig.build.json" + }, + "dependencies": { + "@pow-captcha/solver": "workspace:*", + "@pow-captcha/shared": "workspace:*" + } +} diff --git a/pkgs/server/src/lib.ts b/pkgs/server/src/lib.ts new file mode 100644 index 0000000..bf4c97a --- /dev/null +++ b/pkgs/server/src/lib.ts @@ -0,0 +1,88 @@ +import { utils, wire } from "@pow-captcha/shared"; +import * as solver from "@pow-captcha/solver"; + +export type CreateChallengesOptions = { + /** @default 50 */ + challengeCount?: number; + /** @default 16 */ + challengeLength?: number; + /** @default 2 */ + difficulty?: number; +}; + +export async function createChallenges( + { + challengeCount = 50, + challengeLength = 16, + difficulty = 2, + }: CreateChallengesOptions, + secret: string, +): Promise { + const challenges = utils.createArray( + challengeCount, + (): wire.ChallengeEntry => { + const challenge = new Uint8Array(challengeLength); + crypto.getRandomValues(challenge); + + const target = new Uint8Array(difficulty); + crypto.getRandomValues(target); + + return [ + wire.serializeArray(challenge), + wire.serializeArray(target), + ]; + }, + ); + + const challenge: wire.Challenge = { + magic: wire.CHALLENGE_MAGIC, + challenges, + }; + return await wire.serializeAndSignData(challenge, secret); +} + +export async function redeemChallengeSolution( + { challengesSigned, solutions }: wire.ChallengeSolution, + secret: string, +): Promise { + const challengesWire = await wire.verifyAndDeserializeData( + challengesSigned, + wire.challengeSchema, + secret, + ); + + if (challengesWire.challenges.length !== solutions.length) { + throw new Error( + `Number of solutions does not match the number of challenges`, + ); + } + + const verifyTasks = challengesWire.challenges.map(async (challenge, i) => { + const solution = solutions[i]!; + const isValid = await solver.verify( + wire.deserializeArray(challenge[0]), + wire.deserializeArray(challenge[1]), + wire.deserializeArray(solution), + ); + if (!isValid) { + throw new Error(`The solution with index ${i} is invalid`); + } + }); + await Promise.all(verifyTasks); + + const redeemed: wire.Redeemed = { + magic: wire.REDEEMED_MAGIC, + }; + return await wire.serializeAndSignData(redeemed, secret); +} + +export async function verifyRedeemed( + redeemedSigned: wire.SignedData, + secret: string, +): Promise { + await wire.verifyAndDeserializeData( + redeemedSigned, + wire.redeemedSchema, + secret, + ); +} diff --git a/pkgs/server/tsconfig.build.json b/pkgs/server/tsconfig.build.json new file mode 100644 index 0000000..b284420 --- /dev/null +++ b/pkgs/server/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src/**/*"], + "exclude": ["**/*.spec.ts"] +} diff --git a/pkgs/server/tsconfig.json b/pkgs/server/tsconfig.json new file mode 100644 index 0000000..269232f --- /dev/null +++ b/pkgs/server/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*"] +} diff --git a/pkgs/shared/package.json b/pkgs/shared/package.json new file mode 100644 index 0000000..b83b6e1 --- /dev/null +++ b/pkgs/shared/package.json @@ -0,0 +1,13 @@ +{ + "name": "@pow-captcha/shared", + "version": "0.0.0", + "module": "dist/lib.js", + "types": "dist/lib.d.ts", + "scripts": { + "build": "tsc -p tsconfig.build.json" + }, + "dependencies": { + "base64-js": "^1.5.1", + "zod": "^3.25.67" + } +} diff --git a/pkgs/shared/src/lib.ts b/pkgs/shared/src/lib.ts new file mode 100644 index 0000000..de8bd1f --- /dev/null +++ b/pkgs/shared/src/lib.ts @@ -0,0 +1,2 @@ +export * as wire from "./wire"; +export * as utils from "./utils"; diff --git a/pkgs/shared/src/utils.ts b/pkgs/shared/src/utils.ts new file mode 100644 index 0000000..6495e81 --- /dev/null +++ b/pkgs/shared/src/utils.ts @@ -0,0 +1,6 @@ +export function createArray( + length: number, + f: (index: number) => T, +): Array { + return Array.from({ length }, (_, index) => f(index)); +} diff --git a/pkgs/shared/src/wire.ts b/pkgs/shared/src/wire.ts new file mode 100644 index 0000000..fcbe412 --- /dev/null +++ b/pkgs/shared/src/wire.ts @@ -0,0 +1,72 @@ +import { z } from "zod"; +import { + fromByteArray as serializeArray, + toByteArray as deserializeArray, +} from "base64-js"; + +export { serializeArray, deserializeArray }; + +export const signedDataSchema = z.object({ + data: z.string(), + hash: z.string(), +}); +export type SignedData = z.output; + +export const challengeEntrySchema = z.tuple([z.string(), z.string()]); +export type ChallengeEntry = z.output; + +export const CHALLENGE_MAGIC = "2104f639-ba1b-48f3-9443-889128163f5a"; + +export const challengeSchema = z.object({ + magic: z.literal(CHALLENGE_MAGIC), + challenges: z.array(challengeEntrySchema), +}); +export type Challenge = z.output; + +export const challengeSolutionSchema = z.object({ + challengesSigned: signedDataSchema, + solutions: z.array(z.string()), +}); +export type ChallengeSolution = z.output; + +export const REDEEMED_MAGIC = "90a63087-993a-4376-9532-33c3dc8557c9"; + +export const redeemedSchema = z.object({ + magic: z.literal(REDEEMED_MAGIC), +}); +export type Redeemed = z.output; + +export async function serializeAndSignData( + data: T, + secret: string, +): Promise { + const json = JSON.stringify(data); + const arr = utf16StringToArrayBuffer(`${json}:${secret}`); + const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", arr)); + return { + data: json, + hash: serializeArray(hash), + }; +} + +export async function verifyAndDeserializeData( + signedData: SignedData, + schema: z.ZodType, + secret: string, +): Promise { + const arr = utf16StringToArrayBuffer(`${signedData.data}:${secret}`); + const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", arr)); + if (hash !== deserializeArray(signedData.hash)) { + throw new Error(`Signed data verification failed, hash mismatch`); + } + const data = JSON.parse(signedData.data); + return await schema.parseAsync(data); +} + +export function utf16StringToArrayBuffer(input: string): ArrayBuffer { + const arr = new Uint16Array(input.length); + for (let i = 0, strLen = input.length; i < strLen; i += 1) { + arr[i] = input.charCodeAt(i); + } + return arr.buffer; +} diff --git a/pkgs/shared/tsconfig.build.json b/pkgs/shared/tsconfig.build.json new file mode 100644 index 0000000..b284420 --- /dev/null +++ b/pkgs/shared/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src/**/*"], + "exclude": ["**/*.spec.ts"] +} diff --git a/pkgs/shared/tsconfig.json b/pkgs/shared/tsconfig.json new file mode 100644 index 0000000..269232f --- /dev/null +++ b/pkgs/shared/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*"] +} diff --git a/pkgs/solver/src/lib.ts b/pkgs/solver/src/lib.ts index 35df7a4..916db6f 100644 --- a/pkgs/solver/src/lib.ts +++ b/pkgs/solver/src/lib.ts @@ -1,3 +1,56 @@ -export function solve(): void { +export async function solveJs( + nonce: Uint8Array, + target: Uint8Array, +): Promise { + const arr = new Uint8Array(8 + nonce.byteLength); + const solutionView = new DataView(arr.buffer, 0, 8); + arr.set(nonce, 8); + for ( + let i = BigInt(0); + // eslint-disable-next-line no-constant-condition + true; + i++ + ) { + solutionView.setBigUint64(0, i, true); + + const hashArrayBuf = await crypto.subtle.digest("SHA-256", arr); + const hash = new Uint8Array(hashArrayBuf); + + if (arrayStartsWith(hash, target)) { + return new Uint8Array( + solutionView.buffer, + solutionView.byteOffset, + solutionView.byteLength, + ); + } + } +} + +export async function verify( + nonce: Uint8Array, + target: Uint8Array, + solution: Uint8Array, +): Promise { + const arr = new Uint8Array(solution.byteLength + nonce.byteLength); + arr.set(solution); + arr.set(nonce, solution.byteLength); + + const hashArrayBuf = await crypto.subtle.digest("SHA-256", arr); + const hash = new Uint8Array(hashArrayBuf); + + return arrayStartsWith(hash, target); +} + +function arrayStartsWith(array: Uint8Array, search: Uint8Array): boolean { + const searchLen = search.length; + if (searchLen > array.length) { + return false; + } + for (let i = 0; i < searchLen; i += 1) { + if (array[i] !== search[i]) { + return false; + } + } + return true; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 47c8d32..ca74575 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,12 +33,24 @@ importers: specifier: ^8.35.0 version: 8.35.0(eslint@9.29.0)(typescript@5.8.3) - pkgs/client: + pkgs/server: dependencies: + '@pow-captcha/shared': + specifier: workspace:* + version: link:../shared '@pow-captcha/solver': specifier: workspace:* version: link:../solver + pkgs/shared: + dependencies: + base64-js: + specifier: ^1.5.1 + version: 1.5.1 + zod: + specifier: ^3.25.67 + version: 3.25.67 + pkgs/solver: {} packages: @@ -208,6 +220,9 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -590,6 +605,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod@3.25.67: + resolution: {integrity: sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==} + snapshots: '@eslint-community/eslint-utils@4.7.0(eslint@9.29.0)': @@ -786,6 +804,8 @@ snapshots: balanced-match@1.0.2: {} + base64-js@1.5.1: {} + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -1135,3 +1155,5 @@ snapshots: word-wrap@1.2.5: {} yocto-queue@0.1.0: {} + + zod@3.25.67: {}