feat: add basic impl

This commit is contained in:
2025-06-26 21:31:32 +02:00
parent 8b3c059210
commit 19e97d523a
12 changed files with 295 additions and 2 deletions

13
pkgs/server/package.json Normal file
View File

@@ -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:*"
}
}

88
pkgs/server/src/lib.ts Normal file
View File

@@ -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<wire.SignedData> {
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<wire.SignedData> {
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<void> {
await wire.verifyAndDeserializeData(
redeemedSigned,
wire.redeemedSchema,
secret,
);
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.build.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src/**/*"],
"exclude": ["**/*.spec.ts"]
}

View File

@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src/**/*"]
}

13
pkgs/shared/package.json Normal file
View File

@@ -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"
}
}

2
pkgs/shared/src/lib.ts Normal file
View File

@@ -0,0 +1,2 @@
export * as wire from "./wire";
export * as utils from "./utils";

6
pkgs/shared/src/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
export function createArray<T>(
length: number,
f: (index: number) => T,
): Array<T> {
return Array.from({ length }, (_, index) => f(index));
}

72
pkgs/shared/src/wire.ts Normal file
View File

@@ -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<typeof signedDataSchema>;
export const challengeEntrySchema = z.tuple([z.string(), z.string()]);
export type ChallengeEntry = z.output<typeof challengeEntrySchema>;
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<typeof challengeSchema>;
export const challengeSolutionSchema = z.object({
challengesSigned: signedDataSchema,
solutions: z.array(z.string()),
});
export type ChallengeSolution = z.output<typeof challengeSolutionSchema>;
export const REDEEMED_MAGIC = "90a63087-993a-4376-9532-33c3dc8557c9";
export const redeemedSchema = z.object({
magic: z.literal(REDEEMED_MAGIC),
});
export type Redeemed = z.output<typeof redeemedSchema>;
export async function serializeAndSignData<T>(
data: T,
secret: string,
): Promise<SignedData> {
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<T>(
signedData: SignedData,
schema: z.ZodType<T>,
secret: string,
): Promise<T> {
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;
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.build.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src/**/*"],
"exclude": ["**/*.spec.ts"]
}

View File

@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src/**/*"]
}

View File

@@ -1,3 +1,56 @@
export function solve(): void {
export async function solveJs(
nonce: Uint8Array,
target: Uint8Array,
): Promise<Uint8Array> {
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<boolean> {
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;
}