feat: add basic impl
This commit is contained in:
13
pkgs/server/package.json
Normal file
13
pkgs/server/package.json
Normal 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
88
pkgs/server/src/lib.ts
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
8
pkgs/server/tsconfig.build.json
Normal file
8
pkgs/server/tsconfig.build.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.build.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["**/*.spec.ts"]
|
||||||
|
}
|
||||||
4
pkgs/server/tsconfig.json
Normal file
4
pkgs/server/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
13
pkgs/shared/package.json
Normal file
13
pkgs/shared/package.json
Normal 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
2
pkgs/shared/src/lib.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * as wire from "./wire";
|
||||||
|
export * as utils from "./utils";
|
||||||
6
pkgs/shared/src/utils.ts
Normal file
6
pkgs/shared/src/utils.ts
Normal 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
72
pkgs/shared/src/wire.ts
Normal 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;
|
||||||
|
}
|
||||||
8
pkgs/shared/tsconfig.build.json
Normal file
8
pkgs/shared/tsconfig.build.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.build.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["**/*.spec.ts"]
|
||||||
|
}
|
||||||
4
pkgs/shared/tsconfig.json
Normal file
4
pkgs/shared/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
24
pnpm-lock.yaml
generated
24
pnpm-lock.yaml
generated
@@ -33,12 +33,24 @@ importers:
|
|||||||
specifier: ^8.35.0
|
specifier: ^8.35.0
|
||||||
version: 8.35.0(eslint@9.29.0)(typescript@5.8.3)
|
version: 8.35.0(eslint@9.29.0)(typescript@5.8.3)
|
||||||
|
|
||||||
pkgs/client:
|
pkgs/server:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@pow-captcha/shared':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../shared
|
||||||
'@pow-captcha/solver':
|
'@pow-captcha/solver':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../solver
|
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: {}
|
pkgs/solver: {}
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
@@ -208,6 +220,9 @@ packages:
|
|||||||
balanced-match@1.0.2:
|
balanced-match@1.0.2:
|
||||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||||
|
|
||||||
|
base64-js@1.5.1:
|
||||||
|
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||||
|
|
||||||
brace-expansion@1.1.12:
|
brace-expansion@1.1.12:
|
||||||
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
|
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
|
||||||
|
|
||||||
@@ -590,6 +605,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
zod@3.25.67:
|
||||||
|
resolution: {integrity: sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==}
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@eslint-community/eslint-utils@4.7.0(eslint@9.29.0)':
|
'@eslint-community/eslint-utils@4.7.0(eslint@9.29.0)':
|
||||||
@@ -786,6 +804,8 @@ snapshots:
|
|||||||
|
|
||||||
balanced-match@1.0.2: {}
|
balanced-match@1.0.2: {}
|
||||||
|
|
||||||
|
base64-js@1.5.1: {}
|
||||||
|
|
||||||
brace-expansion@1.1.12:
|
brace-expansion@1.1.12:
|
||||||
dependencies:
|
dependencies:
|
||||||
balanced-match: 1.0.2
|
balanced-match: 1.0.2
|
||||||
@@ -1135,3 +1155,5 @@ snapshots:
|
|||||||
word-wrap@1.2.5: {}
|
word-wrap@1.2.5: {}
|
||||||
|
|
||||||
yocto-queue@0.1.0: {}
|
yocto-queue@0.1.0: {}
|
||||||
|
|
||||||
|
zod@3.25.67: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user