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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,11 +8,26 @@ import { arrayStartsWith, chunkArray } from "./utils";
export async function solveJs( export async function solveJs(
nonce: Uint8Array, nonce: Uint8Array,
target: Uint8Array, target: Uint8Array,
difficultyBits: number,
): Promise<Uint8Array> { ): 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 arr = new Uint8Array(8 + nonce.byteLength);
const solutionView = new DataView(arr.buffer, 0, 8); const solutionView = new DataView(arr.buffer, 0, 8);
arr.set(nonce, 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 ( for (
let i = BigInt(0); let i = BigInt(0);
// eslint-disable-next-line no-constant-condition // eslint-disable-next-line no-constant-condition
@@ -25,20 +40,27 @@ export async function solveJs(
const hashArrayBuf = await crypto.subtle.digest("SHA-256", arr); const hashArrayBuf = await crypto.subtle.digest("SHA-256", arr);
const hash = new Uint8Array(hashArrayBuf); const hash = new Uint8Array(hashArrayBuf);
if (arrayStartsWith(hash, target)) { if (arrayStartsWith(hash, targetWholeBytes)) {
return new Uint8Array( if (
solutionView.buffer, targetRest === null ||
solutionView.byteOffset, (hash[targetWholeBytes.length]! & targetRest[0]) ===
solutionView.byteLength, targetRest[1]
); ) {
return new Uint8Array(
solutionView.buffer,
solutionView.byteOffset,
solutionView.byteLength,
);
}
} }
} }
} }
export async function solveChallenges( export async function solveChallenges({
challenges: ReadonlyArray<readonly [Uint8Array, Uint8Array]>, challenges,
engine?: "wasm" | "js", engine,
): Promise<Array<Uint8Array>> { difficultyBits,
}: WorkerRequest): Promise<Array<Uint8Array>> {
const workerChallenges = chunkArray( const workerChallenges = chunkArray(
Math.floor(challenges.length / navigator.hardwareConcurrency), Math.floor(challenges.length / navigator.hardwareConcurrency),
challenges, challenges,
@@ -81,7 +103,7 @@ export async function solveChallenges(
}, },
); );
const req: WorkerRequest = { challenges, engine }; const req: WorkerRequest = { challenges, engine, difficultyBits };
worker.postMessage(req); worker.postMessage(req);
return await resultPromise; return await resultPromise;
@@ -96,8 +118,13 @@ export async function solveChallenges(
export async function verify( export async function verify(
nonce: Uint8Array, nonce: Uint8Array,
target: Uint8Array, target: Uint8Array,
difficultyBits: number,
solution: Uint8Array, solution: Uint8Array,
): Promise<boolean> { ): 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); const arr = new Uint8Array(solution.byteLength + nonce.byteLength);
arr.set(solution); arr.set(solution);
arr.set(nonce, solution.byteLength); arr.set(nonce, solution.byteLength);
@@ -105,5 +132,18 @@ export async function verify(
const hashArrayBuf = await crypto.subtle.digest("SHA-256", arr); const hashArrayBuf = await crypto.subtle.digest("SHA-256", arr);
const hash = new Uint8Array(hashArrayBuf); 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 // // input.length / numBuckets
// const chunkSize = Math.ceil(); // const chunkSize = Math.ceil();
// return createArray(numBuckets, (bucket): Array<T> => { // return createArray(numBuckets, (bucket): Array<T> => {
// const start = bucket * chunkSize; // const start = bucket * chunkSize;
// return input.slice(start, start + 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({ export const challengeSchema = z.object({
magic: z.literal(CHALLENGE_MAGIC), magic: z.literal(CHALLENGE_MAGIC),
challenges: z.array(challengeEntrySchema), challenges: z.array(challengeEntrySchema),
difficultyBits: z.number(),
}); });
export type Challenge = z.output<typeof challengeSchema>; export type Challenge = z.output<typeof challengeSchema>;

View File

@@ -7,16 +7,37 @@ use sha2::{Digest, Sha256};
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
#[wasm_bindgen] #[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()]; let mut buf = vec![0u8; 8 + nonce.len()];
buf[8..].copy_from_slice(nonce); 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.. { for i in 0u64.. {
let i_bytes = u64::to_le_bytes(i); let i_bytes = u64::to_le_bytes(i);
buf[0..=7].copy_from_slice(&i_bytes); buf[0..=7].copy_from_slice(&i_bytes);
let hash = Sha256::digest(&buf); let hash = Sha256::digest(&buf);
if &hash[0..target.len()] == target {
return i_bytes.into(); 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();
}
} }
} }
@@ -28,7 +49,7 @@ mod tests {
#[test] #[test]
fn solve() { fn solve() {
assert_eq!( 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] [45, 176, 0, 0, 0, 0, 0, 0]
); );
} }