feat: change difficulty from byte count to bits

This commit is contained in:
2025-07-13 21:59:43 +02:00
parent ab57216b6b
commit 6af774d74f
9 changed files with 114 additions and 42 deletions

View File

@@ -10,7 +10,7 @@ async function benchmark(cb: () => void | Promise<void>): Promise<number> {
}
export default function App() {
const [durationJs, setDurationJs] = useState<null | number>(null);
const [durationJs, _setDurationJs] = useState<null | number>(null);
const [durationWasm, setDurationWasm] = useState<null | number>(null);
const [runBenchmark, setRunBenchmark] = useState(false);
@@ -26,8 +26,8 @@ export default function App() {
async function init() {
const challengesRaw = await powCaptcha.server.createChallengesRaw({
difficulty: 2,
challengeCount: 64,
difficultyBits: 18,
challengeCount: 32,
});
const challenges = challengesRaw.challenges.map(
@@ -39,19 +39,21 @@ export default function App() {
);
const durationWasm = await benchmark(async () => {
const solutions = await powCaptcha.solver.solveChallenges(
const solutions = await powCaptcha.solver.solveChallenges({
difficultyBits: challengesRaw.difficultyBits,
challenges,
"wasm",
);
engine: "wasm",
});
console.log("wasm solutions", solutions);
});
setDurationWasm(durationWasm);
// const durationJs = await benchmark(async () => {
// const solutions = await powCaptcha.solver.solveChallenges(
// const solutions = await powCaptcha.solver.solveChallenges({
// difficultyBits: challengesRaw.difficultyBits,
// challenges,
// "js",
// );
// engine: "js",
// });
// console.log("js solutions", solutions);
// });
// setDurationJs(durationJs);

View File

@@ -3,24 +3,24 @@ import * as solver from "./solver";
import { createArray } from "./utils";
export type CreateChallengesOptions = {
/** @default 50 */
/** @default 32 */
challengeCount?: number;
/** @default 16 */
challengeLength?: number;
/** @default 2 */
difficulty?: number;
/** @default 18 */
difficultyBits?: number;
};
export async function createChallengesRaw({
challengeCount = 50,
challengeCount = 32,
challengeLength = 16,
difficulty = 2,
difficultyBits = 18,
}: CreateChallengesOptions): Promise<wire.Challenge> {
const challenges = createArray(challengeCount, (): wire.ChallengeEntry => {
const challenge = new Uint8Array(challengeLength);
crypto.getRandomValues(challenge);
const target = new Uint8Array(difficulty);
const target = new Uint8Array(Math.ceil(difficultyBits / 8));
crypto.getRandomValues(target);
return [wire.serializeArray(challenge), wire.serializeArray(target)];
@@ -28,6 +28,7 @@ export async function createChallengesRaw({
return {
magic: wire.CHALLENGE_MAGIC,
difficultyBits,
challenges,
};
}
@@ -55,6 +56,7 @@ export async function redeemChallengeSolutionRaw(
const isValid = await solver.verify(
wire.deserializeArray(challenge[0]),
wire.deserializeArray(challenge[1]),
challenges.difficultyBits,
wire.deserializeArray(solution),
);
if (!isValid) {

View File

@@ -5,6 +5,7 @@ export type Challenge = readonly [Uint8Array, Uint8Array];
export type WorkerRequest = {
engine?: undefined | "js" | "wasm";
challenges: ReadonlyArray<Challenge>;
difficultyBits: number;
};
export type WorkerResponse = {

View File

@@ -10,29 +10,30 @@ async function solve(
nonce: Uint8Array,
target: Uint8Array,
engine: undefined | "js" | "wasm",
difficultyBits: number,
): Promise<Uint8Array> {
switch (engine) {
case "js":
return await solver.solveJs(nonce, target);
return await solver.solveJs(nonce, target, difficultyBits);
case "wasm":
return wasm.solve(nonce, target);
return wasm.solve(nonce, target, difficultyBits);
case undefined:
try {
return wasm.solve(nonce, target);
return wasm.solve(nonce, target, difficultyBits);
} catch (err) {
console.warn(
"pow-captcha: Falling back to js solver. Error: ",
err,
);
return await solver.solveJs(nonce, target);
return await solver.solveJs(nonce, target, difficultyBits);
}
}
}
async function processMessage(m: MessageEvent<WorkerRequest>): Promise<void> {
const { challenges, engine } = m.data;
const { challenges, engine, difficultyBits } = m.data;
// const solutions = await Promise.all(
// challenges.map(([nonce, target]) => solve(nonce, target, engine)),
@@ -40,7 +41,7 @@ async function processMessage(m: MessageEvent<WorkerRequest>): Promise<void> {
const solutions: Array<Uint8Array> = [];
for (const [nonce, target] of challenges) {
const solution = await solve(nonce, target, engine);
const solution = await solve(nonce, target, engine, difficultyBits);
solutions.push(solution);
}
@@ -50,8 +51,12 @@ async function processMessage(m: MessageEvent<WorkerRequest>): Promise<void> {
postMessage(res);
}
onerror = (m) => {
console.error("pow-captcha: Failure in worker: ", m);
};
onmessage = (m: MessageEvent<WorkerRequest>) => {
console.log("onmessage", m);
console.log("onmessage", m.data);
processMessage(m).catch((err) => {
console.error("pow-captcha: Failure in worker: ", err);
});

View File

@@ -6,7 +6,8 @@ describe("solver", () => {
expect(
await solver.solveJs(
new Uint8Array([1, 2]),
new Uint8Array([3, 4]),
new Uint8Array([3, 4, 5]),
18,
),
).toStrictEqual(new Uint8Array([45, 176, 0, 0, 0, 0, 0, 0]));
});
@@ -15,7 +16,8 @@ describe("solver", () => {
expect(
await solver.verify(
new Uint8Array([1, 2]),
new Uint8Array([3, 4]),
new Uint8Array([3, 4, 5]),
18,
new Uint8Array([45, 176, 0, 0, 0, 0, 0, 0]),
),
).toStrictEqual(true);

View File

@@ -8,11 +8,26 @@ import { arrayStartsWith, chunkArray } from "./utils";
export async function solveJs(
nonce: Uint8Array,
target: Uint8Array,
difficultyBits: number,
): Promise<Uint8Array> {
if (target.length < Math.ceil(difficultyBits / 8)) {
throw new Error(`pow-captcha: target is smaller than difficultyBits`);
}
const arr = new Uint8Array(8 + nonce.byteLength);
const solutionView = new DataView(arr.buffer, 0, 8);
arr.set(nonce, 8);
const targetWholeBytes = target.slice(0, Math.floor(difficultyBits / 8));
let targetRest: null | [number, number] = null;
const targetRestBits = difficultyBits % 8;
if (targetRestBits !== 0) {
const mask = (0xff << (8 - targetRestBits)) & 0xff;
const rest = target[targetWholeBytes.length]! & mask;
targetRest = [mask, rest];
}
for (
let i = BigInt(0);
// eslint-disable-next-line no-constant-condition
@@ -25,7 +40,12 @@ export async function solveJs(
const hashArrayBuf = await crypto.subtle.digest("SHA-256", arr);
const hash = new Uint8Array(hashArrayBuf);
if (arrayStartsWith(hash, target)) {
if (arrayStartsWith(hash, targetWholeBytes)) {
if (
targetRest === null ||
(hash[targetWholeBytes.length]! & targetRest[0]) ===
targetRest[1]
) {
return new Uint8Array(
solutionView.buffer,
solutionView.byteOffset,
@@ -34,11 +54,13 @@ export async function solveJs(
}
}
}
}
export async function solveChallenges(
challenges: ReadonlyArray<readonly [Uint8Array, Uint8Array]>,
engine?: "wasm" | "js",
): Promise<Array<Uint8Array>> {
export async function solveChallenges({
challenges,
engine,
difficultyBits,
}: WorkerRequest): Promise<Array<Uint8Array>> {
const workerChallenges = chunkArray(
Math.floor(challenges.length / navigator.hardwareConcurrency),
challenges,
@@ -81,7 +103,7 @@ export async function solveChallenges(
},
);
const req: WorkerRequest = { challenges, engine };
const req: WorkerRequest = { challenges, engine, difficultyBits };
worker.postMessage(req);
return await resultPromise;
@@ -96,8 +118,13 @@ export async function solveChallenges(
export async function verify(
nonce: Uint8Array,
target: Uint8Array,
difficultyBits: number,
solution: Uint8Array,
): Promise<boolean> {
if (target.length < Math.ceil(difficultyBits / 8)) {
throw new Error(`pow-captcha: target is smaller than difficultyBits`);
}
const arr = new Uint8Array(solution.byteLength + nonce.byteLength);
arr.set(solution);
arr.set(nonce, solution.byteLength);
@@ -105,5 +132,18 @@ export async function verify(
const hashArrayBuf = await crypto.subtle.digest("SHA-256", arr);
const hash = new Uint8Array(hashArrayBuf);
return arrayStartsWith(hash, target);
const targetWholeBytes = target.slice(0, Math.floor(difficultyBits / 8));
if (!arrayStartsWith(hash, targetWholeBytes)) {
return false;
}
const targetRestBits = difficultyBits % 8;
if (targetRestBits === 0) {
return true;
}
const mask = (0xff << (8 - targetRestBits)) & 0xff;
const rest = target[targetWholeBytes.length]! & mask;
return (hash[targetWholeBytes.length]! & mask) === rest;
}

View File

@@ -53,8 +53,6 @@ export function chunkArray<T>(
// // input.length / numBuckets
// const chunkSize = Math.ceil();
// return createArray(numBuckets, (bucket): Array<T> => {
// const start = bucket * chunkSize;
// return input.slice(start, start + chunkSize);

View File

@@ -21,6 +21,7 @@ export const CHALLENGE_MAGIC = "2104f639-ba1b-48f3-9443-889128163f5a";
export const challengeSchema = z.object({
magic: z.literal(CHALLENGE_MAGIC),
challenges: z.array(challengeEntrySchema),
difficultyBits: z.number(),
});
export type Challenge = z.output<typeof challengeSchema>;

View File

@@ -7,18 +7,39 @@ use sha2::{Digest, Sha256};
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn solve(nonce: &[u8], target: &[u8]) -> Box<[u8]> {
pub fn solve(nonce: &[u8], target: &[u8], difficulty_bits: u32) -> Box<[u8]> {
let mut buf = vec![0u8; 8 + nonce.len()];
buf[8..].copy_from_slice(nonce);
let target_whole_bytes = &target[0..(difficulty_bits as usize / 8)];
let target_rest = {
let rest_bits = difficulty_bits % 8;
match rest_bits {
0 => None,
_ => {
let mask = 0xffu8.unbounded_shl(8 - rest_bits);
let rest = target[target_whole_bytes.len()] & mask;
Some((mask, rest))
}
}
};
for i in 0u64.. {
let i_bytes = u64::to_le_bytes(i);
buf[0..=7].copy_from_slice(&i_bytes);
let hash = Sha256::digest(&buf);
if &hash[0..target.len()] == target {
if &hash[0..target_whole_bytes.len()] == target_whole_bytes {
let target_rest_ok = match target_rest {
None => true,
Some((mask, rest)) => (hash[target_whole_bytes.len()] & mask) == rest,
};
if target_rest_ok {
return i_bytes.into();
}
}
}
unreachable!();
}
@@ -28,7 +49,7 @@ mod tests {
#[test]
fn solve() {
assert_eq!(
super::solve(&[1, 2], &[3, 4]).as_ref(),
super::solve(&[1, 2], &[3, 4, 5], 18).as_ref(),
[45, 176, 0, 0, 0, 0, 0, 0]
);
}