1 Commits
kctf ... trunk

Author SHA1 Message Date
Zoe
9ba5b12dac Clean up code. Reorganize files. Port stuff from other branches. + more
This turns the project into a monorepo using pnpm workspaces,
dramatically simplifying the build process. It also fixes a lot of bugs
and just generally makes the codebase a lot cleaner.
2025-12-04 18:48:00 -06:00
42 changed files with 10832 additions and 1636 deletions

1
.gitignore vendored
View File

@@ -0,0 +1 @@
node_modules

View File

@@ -14,6 +14,6 @@ This is the impost monorepo, containing the following packages:
- `@impost/lib`: A library that can be used to generate, solve, and verify
proofs.
It also contains a `solver` package, which is the PoW solver written in Zig,
`@impost/lib` is built on top of, an example of how to use the solver in a
nuxt 3 project.
It also contains a `solver` package, which is the Zig PoW solver that
`@impost/lib` is built on top of, an example of how to use the solver in
example-app

View File

@@ -1 +1 @@
YAPTCHA_HMAC_SECRET=xxx # openssl rand -base64 32
IMPOST_HMAC_SECRET=xxx # openssl rand -base64 32

View File

@@ -1,10 +1,10 @@
# YAPTCHA
# IMPOST
Yet Another Pow capTCHA.
## What is this
YAPTCHA is a proof of work based challenge-response system that is designed to
IMPOST is a proof of work based challenge-response system that is designed to
ward off spam and abuse.
<!-- TODO: -->

View File

@@ -9,14 +9,7 @@ if (!challengeData) {
}
function solved(ev: CustomEvent) {
console.log("Solved:", ev.detail.solution);
// $fetch('/api/pow/challenge', {
// method: 'POST',
// body: JSON.stringify({
// challenge: ev.detail.challenge,
// solution: ev.detail.solution,
// }),
// });
console.log("Impost Solved:", ev.detail.solution);
}
function formsubmit(ev: Event) {
@@ -26,9 +19,9 @@ function formsubmit(ev: Event) {
<template>
<div class="flex justify-center items-center h-screen w-screen ">
<form @submit.prevent="formsubmit"
<form @submit="formsubmit" action="/"
class="p-5 rounded-2xl bg-dark-9 border-coolGray-600 border flex flex-col gap-2">
<pow-captcha challengeUrl="/api/pow" auto="onsubmit"
<impost-captcha name="impost" challengeUrl="/api/pow" auto="onsubmit"
:challengejson="JSON.stringify(challengeData!.challenge)" @impost:solved="solved" />
<input class="bg-blue-7 text-white font-semibold px-4 py-2.5 border-0 rounded-md" type="submit"
value="Submit" />

View File

@@ -1,411 +0,0 @@
<script setup lang="ts">
import { type WorkerRequest, type SolutionMessage, WorkerResponseType, WorkerMessageType, ChallengeStrategy, type Challenge } from '~/types/pow';
import WASMSolverUrl from "~/utils/solver.wasm?url";
import ChallengeWorker from '~/utils/worker?worker';
let shared_atomics = new SharedArrayBuffer(12);
let workers: Worker[] = [];
let workers_initialized = false;
if (import.meta.client) {
try {
// const num_workers = 1;
const num_workers = navigator.hardwareConcurrency;
for (let i = 0; i < num_workers; i++) {
workers.push(new ChallengeWorker());
}
} catch (error: any) {
console.error("Failed to create worker:", error);
}
}
let autoSolve: Ref<boolean> = ref(false);
watch(autoSolve, async () => {
if (autoSolve.value) {
while (autoSolve.value) {
if (solving.value) {
await new Promise(resolve => setTimeout(resolve, 100));
continue;
}
await getChallenge();
await solveChallenge();
if (!autoSolve.value) {
break;
}
await new Promise(resolve => setTimeout(resolve, 100));
}
}
});
let total_solved: Ref<number> = ref(0);
let total_solving_for: Ref<number> = ref(0);
function pluralize(value: number, string: string) {
return value === 1 ? string : `${string}s`;
}
let challenge_loading: Ref<boolean> = ref(false);
let challenge_loading_indicator: Ref<string> = ref('');
let challenge: Ref<Challenge | null> = ref(null);
let nonce: Ref<string | null> = ref(null);
let solved: Ref<boolean> = ref(false);
let solving_for: Ref<string> = ref('0');
const number_formatter = new Intl.NumberFormat('en-US', {
notation: 'compact',
compactDisplay: 'short',
maximumFractionDigits: 2,
});
// the hashrate of all the runs
let hashrate_array: Ref<Array<number>> = ref([]);
let hashrate = computed(() => {
if (hashrate_array.value.length === 0) {
return 0;
}
return hashrate_array.value.reduce((a, b) => a + b, 0) / hashrate_array.value.length;
});
let solving = ref(false);
let solveTime: Ref<number> = ref(0);
let solveTimeout: Ref<any | null> = ref(null);
let challenge_error: Ref<string | null> = ref(null);
let difficulty: Ref<number> = ref(0);
let { data } = await useFetch('/api/pow/difficulty');
if (data.value) {
difficulty.value = data.value.difficulty;
}
const MESSAGE_TIMEOUT = 3000;
async function getChallenge() {
challenge_error.value = null;
challenge_loading_indicator.value = '';
challenge.value = null;
nonce.value = null;
challenge_loading.value = true;
const spinners = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
let spinner_index = 0;
const loading_interval = setInterval(() => {
challenge_loading_indicator.value = spinners[spinner_index]!;
spinner_index = (spinner_index + 1) % spinners.length;
console.log(spinners[spinner_index]);
}, 100);
try {
const new_challenge = await $fetch('/api/pow/challenge');
challenge.value = new_challenge.challenge;
} catch (error: any) {
console.error("Failed to get challenge:", error);
challenge_error.value = `Failed to get challenge: ${error.message}`;
} finally {
challenge_loading.value = false;
clearInterval(loading_interval);
}
}
async function initWorkers() {
if (workers_initialized) {
throw createError("Workers already initialized");
}
workers_initialized = true;
const module = await WebAssembly.compileStreaming(fetch(WASMSolverUrl));
const atomics_view = new Int32Array(shared_atomics);
Atomics.store(atomics_view, 0, 0);
Atomics.store(atomics_view, 1, 0);
console.debug(`Initializing ${workers.length} workers`);
let worker_promises: Promise<void>[] = [];
for (let i = 0; i < workers.length; i++) {
const worker = workers[i]!;
worker_promises.push(new Promise<void>((resolve, reject) => {
const message_handler = (event: MessageEvent<SolutionMessage>) => {
if (event.data.type === WorkerResponseType.Error) {
console.error("Worker error:", event.data.error);
reject(event.data.error);
}
if (event.data.type === WorkerResponseType.Ok) {
resolve();
}
reject(new Error("Unexpected message from worker"));
};
const error_handler = (error: ErrorEvent) => {
console.error("Worker error:", error);
reject(error);
};
worker.addEventListener('message', message_handler);
worker.addEventListener('error', error_handler);
worker.postMessage({
type: WorkerMessageType.Init,
module: module,
sab: shared_atomics,
} as WorkerRequest);
}));
}
const timeoutMs = 10 * 1000;
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => {
reject(new Error(`Function timed out after ${timeoutMs}ms`));
}, timeoutMs);
});
await Promise.race([
Promise.all(worker_promises),
timeoutPromise,
]);
console.log("All workers initialized");
}
async function getChallengeSolution(worker: Worker, request: { strategy: ChallengeStrategy.LeadingZeroes, target: string, difficulty: number } | { strategy: ChallengeStrategy.TargetNumber, target: string, salt: string }): Promise<SolutionMessage> {
return new Promise<SolutionMessage>((resolve, reject) => {
const message_handler = (event: MessageEvent<SolutionMessage>) => {
worker.removeEventListener('message', message_handler);
worker.removeEventListener('error', error_handler);
resolve(event.data);
};
const error_handler = (error: ErrorEvent) => {
worker.removeEventListener('error', error_handler);
worker.removeEventListener('message', message_handler);
console.error("Worker error:", error);
reject(error);
};
worker.addEventListener('message', message_handler);
worker.addEventListener('error', error_handler);
switch (request.strategy) {
case ChallengeStrategy.LeadingZeroes:
worker.postMessage({
strategy: ChallengeStrategy.LeadingZeroes,
target: request.target,
difficulty: request.difficulty,
} as WorkerRequest);
break;
case ChallengeStrategy.TargetNumber:
worker.postMessage({
strategy: ChallengeStrategy.TargetNumber,
target: request.target,
salt: request.salt,
} as WorkerRequest);
break;
}
});
}
async function solveChallenge() {
if (!challenge.value?.target) {
return;
}
if (!workers_initialized) {
try {
await initWorkers();
} catch (error: any) {
console.error("Failed to initialize workers:", error);
return;
}
}
const atomics_view = new Int32Array(shared_atomics);
Atomics.store(atomics_view, 0, 0);
Atomics.store(atomics_view, 1, -1);
solved.value = false;
challenge_error.value = null;
solving.value = true;
solveTime.value = 0;
solving_for.value = '0';
let startTime = performance.now();
let solving_for_interval = setInterval(() => {
solving_for.value = ((performance.now() - startTime) / 1000).toFixed(1);
}, 100);
function cleanup() {
clearTimeout(solveTimeout.value);
solveTimeout.value = setTimeout(() => {
solveTime.value = 0;
}, MESSAGE_TIMEOUT);
solved.value = false;
solving.value = false;
clearInterval(solving_for_interval);
}
try {
let request: { strategy: ChallengeStrategy.LeadingZeroes, target: string, difficulty: number } | { strategy: ChallengeStrategy.TargetNumber, target: string, salt: string };
switch (challenge.value.strategy) {
case ChallengeStrategy.LeadingZeroes:
request = {
strategy: ChallengeStrategy.LeadingZeroes,
target: challenge.value.target,
difficulty: challenge.value.difficulty,
};
break;
case ChallengeStrategy.TargetNumber:
request = {
strategy: ChallengeStrategy.TargetNumber,
target: challenge.value.target,
salt: challenge.value.salt,
};
break;
}
let worker_promises: Promise<SolutionMessage>[] = [];
for (let worker of workers) {
// dispatch to all workers, func is async so it will not block
worker_promises.push(getChallengeSolution(worker, request));
}
let solution = await Promise.race(worker_promises);
if (solution.type === WorkerResponseType.Error) {
throw createError(solution.error);
}
if (solution.type === WorkerResponseType.Ok) {
throw createError("spurious solution");
}
console.log(shared_atomics.slice(8, 12));
nonce.value = Atomics.load(atomics_view, 1).toString();
solveTime.value = Math.floor(performance.now() - startTime);
total_solved.value += 1;
total_solving_for.value += solveTime.value;
// since nonce is the number of iterations we have completed, we can divide that by how long it took in second
// to get H/s
hashrate_array.value.push(+Atomics.load(atomics_view, 1) / (solveTime.value / 1000));
clearTimeout(solveTimeout.value);
await $fetch('/api/pow/challenge', {
method: 'POST',
body: {
challenge: challenge.value.target,
nonce: nonce.value,
}
});
solved.value = true;
solveTimeout.value = setTimeout(() => {
solveTime.value = 0;
solved.value = false;
}, MESSAGE_TIMEOUT);
switch (challenge.value.strategy) {
case ChallengeStrategy.LeadingZeroes:
console.debug("Solved challenge with difficulty", challenge.value.difficulty, "in " + solveTime.value + "ms");
break;
case ChallengeStrategy.TargetNumber:
console.debug("Solved challenge with salt", challenge.value.salt, "in " + solveTime.value + "ms");
break;
}
solving.value = false;
clearInterval(solving_for_interval);
} catch (error: any) {
challenge_error.value = `Failed to solve challenge: ${error.message}`;
console.error(error);
cleanup();
}
}
async function setDifficulty(difficulty: number) {
const response = await $fetch('/api/pow/difficulty', {
method: 'PUT',
body: {
difficulty,
}
});
console.log(response);
}
</script>
<template>
<div class="flex justify-between" v-if="hashrate_array.length !== 0">
<span>Your average Hashrate: {{ number_formatter.format(hashrate) }} H/s</span>
<span>You have solved {{ total_solved }} {{ pluralize(total_solved, "challenge") }} in
{{ number_formatter.format(total_solving_for / 1000) }}s</span>
<span>Your Hashrate on the last challenge: {{ number_formatter.format(hashrate_array.at(-1)!) }} H/s</span>
</div>
<p v-else>You have not solved any challenges yet</p>
<p>Challenge: <span v-if="challenge_loading">{{ challenge_loading_indicator }}</span>
<span v-else>{{ challenge }}</span>
</p>
<p>Nonce: {{ nonce }}</p>
<button @click="getChallenge()" :disabled="solving || challenge_loading">
Get Challenge
</button>
<button @click="solveChallenge()" :disabled="solving || challenge === null || nonce !== null">
<span v-if="!solving">Solve Challenge</span>
<span v-else>Solving challenge for {{ solving_for }}s...</span>
</button>
<div>
<div v-if="solveTime && !challenge_error" class="min-h-[1rem]">
<span v-if="solved">Challenge solved in {{ solveTime }}ms!</span>
<span v-else>Validating solution...</span>
</div>
<div v-else-if="challenge_error">{{ challenge_error }}</div>
<p v-else class="empty-p"><!-- Empty so there is no content shift when there is text or isnt --></p>
</div>
<div class="flex flex-row w-fit">
<label for="autoSolve">Auto solve</label>
<input class="w-min" type="checkbox" v-model="autoSolve" id="autoSolve"></input>
</div>
<div class="flex flex-col w-fit">
<label for="difficulty">Difficulty</label>
<input class="w-min" type="number" min="1" max="64" v-model="difficulty" @change="setDifficulty(difficulty)"
id="difficulty"></input>
</div>
</template>
<style scoped>
button {
min-width: 140px;
padding: 0.25rem 0.375rem;
}
.empty-p {
margin: 0;
&::after {
content: "-";
visibility: hidden;
}
}
</style>

View File

@@ -1,6 +0,0 @@
const adjectives = ['swift', 'silent', 'hidden', 'clever', 'brave', 'sharp', 'shadow', 'crimson', 'bright', 'quiet', 'loud', 'happy', 'dark', 'evil', 'good', 'intelligent', 'lovely', 'mysterious', 'peaceful', 'powerful', 'pure', 'quiet', 'shiny', 'sleepy', 'strong', 'sweet', 'tall', 'warm', 'gentle', 'kind', 'nice', 'polite', 'rough', 'rude', 'scary', 'shy', 'silly', 'smart', 'strange', 'tough', 'ugly', 'vivid', 'wicked', 'wise', 'young', 'sleepy'];
const nouns = ['fox', 'river', 'stone', 'cipher', 'link', 'comet', 'falcon', 'signal', 'anchor', 'spark', 'stone', 'comet', 'rocket', 'snake', 'snail', 'shark', 'elephant', 'cat', 'dog', 'whale', 'orca', 'cactus', 'flower', 'frog', 'toad', 'apple', 'strawberry', 'raspberry', 'lemon', 'bot', 'gopher', 'dinosaur', 'racoon', 'penguin', 'chameleon', 'atom', 'particle', 'witch', 'wizard', 'warlock', 'deer']
export function getWorkerName() {
return `${adjectives[Math.floor(Math.random() * adjectives.length)]}-${nouns[Math.floor(Math.random() * nouns.length)]}`;
}

View File

@@ -1,170 +0,0 @@
// This worker just sits on another thread and waits for message to solve
// challenges so that we dont block the render thread
import {
type WorkerRequest,
type SolutionMessage,
WorkerMessageType,
WorkerResponseType,
ChallengeStrategy,
} from "~/types/pow";
const worker_name = getWorkerName();
let solver: SolverModule | null = null;
let atomic_nonce: Int32Array | null = null;
let atomic_solution: Int32Array | null = null;
async function loadWasmSolver(module: WebAssembly.Module) {
if (atomic_nonce === null || atomic_solution === null) {
throw createError("Atomics not initialized");
}
console.debug(`[${worker_name}]: Loading WASM solver`);
solver = await WebAssembly.instantiate(module, {
env: {
__get_solution: () => Atomics.load(atomic_solution!, 0),
__set_solution: (value: number) => Atomics.store(atomic_solution!, 0, value),
__cmpxchg_solution: (expected: number, replacement: number) => Atomics.compareExchange(atomic_solution!, 0, expected, replacement),
__fetch_add_nonce: (value: number) => Atomics.add(atomic_nonce!, 0, value),
__log: (ptr: number, len: number) => {
const string_data = new Uint8Array(solver!.exports.memory.buffer, ptr, len);
console.log(`[${worker_name}]: ${new TextDecoder().decode(string_data)}`);
},
}
}) as unknown as SolverModule;
console.debug(`[${worker_name}]: WASM solver loaded`);
}
onmessage = async (event: MessageEvent<WorkerRequest>) => {
if (event.data.type === WorkerMessageType.Init) {
console.log(`[${worker_name}]: Initializing...`);
atomic_nonce = new Int32Array(event.data.sab, 0, 1);
atomic_solution = new Int32Array(event.data.sab, 4, 1);
try {
await loadWasmSolver(event.data.module);
} catch (error: any) {
console.error(`[${worker_name}]: Failed to load WASM solver:`, error);
postMessage({
type: WorkerResponseType.Error,
error: `Could not load WASM solver: ${error.message}`,
} as SolutionMessage);
return;
}
if (!solver) {
console.error(`[${worker_name}]: Failed to load WASM solver`);
postMessage({
type: WorkerResponseType.Error,
error: "Failed to load WASM solver",
} as SolutionMessage);
return;
}
postMessage({
type: WorkerResponseType.Ok,
} as SolutionMessage);
return;
}
if (!solver) {
postMessage({
type: WorkerResponseType.Error,
error: "WASM solver not loaded",
} as SolutionMessage);
return;
}
const { strategy } = event.data;
const encoder = new TextEncoder();
let solution: number;
let target: string = event.data.target;
let target_bytes, target_ptr;
let memory;
switch (strategy) {
case ChallengeStrategy.LeadingZeroes:
const { difficulty } = event.data;
console.debug(`[${worker_name}]: recieved ${strategy} challenge: ${target}, difficulty: ${difficulty}`);
target_bytes = encoder.encode(target);
target_ptr = solver.exports.malloc(target_bytes.length);
if (target_ptr === 0 || target_ptr === null) {
console.error(`[${worker_name}]: Failed to allocate memory for challenge string`);
postMessage({
type: WorkerResponseType.Error,
error: "Failed to allocate memory for challenge string",
} as SolutionMessage);
return;
}
memory = new Uint8Array(solver.exports.memory.buffer);
memory.set(target_bytes, target_ptr);
solution = solver.exports.solve_leaading_zeroes_challenge(
target_ptr,
target.length,
difficulty,
);
console.debug(`[${worker_name}]: WASM solver found nonce: ${solution}`);
break;
case ChallengeStrategy.TargetNumber:
const { salt } = event.data;
console.debug(`[${worker_name}]: recieved ${strategy} challenge: ${target}, salt: ${salt}`);
const salt_bytes = encoder.encode(salt);
target_bytes = encoder.encode(target);
const salt_ptr = solver.exports.malloc(salt_bytes.length);
if (salt_ptr === 0 || salt_ptr === null) {
console.error(`[${worker_name}]: Failed to allocate memory for salt string`);
postMessage({
type: WorkerResponseType.Error,
error: "Failed to allocate memory for salt string",
} as SolutionMessage);
return;
}
target_ptr = solver.exports.malloc(target_bytes.length);
if (target_ptr === 0 || target_ptr === null) {
console.error(`[${worker_name}]: Failed to allocate memory for target string`);
postMessage({
type: WorkerResponseType.Error,
error: "Failed to allocate memory for target string",
} as SolutionMessage);
return;
}
memory = new Uint8Array(solver.exports.memory.buffer);
memory.set(salt_bytes, salt_ptr);
memory.set(target_bytes, target_ptr);
solution = solver.exports.solve_target_number_challenge(
target_ptr,
target_bytes.length,
salt_ptr,
salt_bytes.length,
);
console.debug(`[${worker_name}]: WASM solver found nonce: ${solution}`);
break;
}
// we are just assuming that if its less than -1, its the min i32
if (solution < 0) {
return postMessage({
type: WorkerResponseType.Error,
error: "failed to solve challenge",
} as SolutionMessage);
}
postMessage({
type: WorkerResponseType.Solution,
nonce: solution === -1 ? null : solution.toString()
} as SolutionMessage);
};

View File

@@ -1,10 +1,7 @@
strategy = "kctf"
strategy = "target_number"
[leading_zeroes]
difficulty = 4
difficulty = 2
[target_number]
max_number = 10000
[kctf]
difficulty = 100
max_number = 192

View File

@@ -19,7 +19,7 @@ export default defineNuxtConfig({
}
},
},
modules: ['@unocss/nuxt', ['nuxt-ssr-lit', { litElementPrefix: 'pow-' }]],
modules: ['@unocss/nuxt', ['nuxt-ssr-lit', { litElementPrefix: 'impost-' }]],
nitro: {
moduleSideEffects: ["@impost/widget"],
@@ -28,17 +28,9 @@ export default defineNuxtConfig({
},
},
imports: {
transform: {
// only necessary in the monorepo since vite is going out to the
// source of the widget and transforming it and clobbering it.
exclude: [/impost/],
}
},
vue: {
compilerOptions: {
isCustomElement: (tag) => tag.includes('pow-'),
isCustomElement: (tag) => tag.startsWith('impost-'),
},
},

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
{
"name": "hello-nuxt",
"name": "example-app",
"private": true,
"type": "module",
"scripts": {
@@ -9,15 +9,13 @@
"preview": "nuxt preview"
},
"dependencies": {
"@lit/reactive-element": "^2.1.1",
"@impost/lib": "workspace:*",
"@impost/widget": "workspace:*",
"js-toml": "^1.0.2",
"lit-element": "^4.2.1",
"lit-html": "^3.3.1",
"nuxt": "latest",
"nuxt-ssr-lit": "1.6.32",
"zod": "^4.1.12",
"@impost/lib": "^0.1.0",
"@impost/widget": "^0.1.0"
"vue": "^3.5.25",
"zod": "^4.1.12"
},
"devDependencies": {
"@types/node": "^24.10.0",

View File

@@ -6,28 +6,19 @@ import { CHALLENGE_TIMEOUT_MS, outstandingChallenges } from '~~/server/utils/pow
export default defineEventHandler(async () => {
let challenge_config;
// switch (config.strategy) {
// case ChallengeStrategy.LeadingZeroes:
// challenge_config = {
// parameters: { expires_at: CHALLENGE_TIMEOUT_MS },
// strategy: config.strategy,
// difficulty: config.leading_zeroes?.difficulty!,
// };
// break;
// case ChallengeStrategy.TargetNumber:
// challenge_config = {
// parameters: { expires_at: CHALLENGE_TIMEOUT_MS },
// strategy: config.strategy,
// max_number: config.target_number.max_number,
// };
// break;
// }
switch (config.strategy) {
case ChallengeStrategy.kCTF:
case ChallengeStrategy.LeadingZeroes:
challenge_config = {
parameters: { expires_at: CHALLENGE_TIMEOUT_MS },
strategy: config.strategy,
difficulty: config.kctf.difficulty,
difficulty: config.leading_zeroes?.difficulty!,
};
break;
case ChallengeStrategy.TargetNumber:
challenge_config = {
parameters: { expires_at: CHALLENGE_TIMEOUT_MS },
strategy: config.strategy,
max_number: config.target_number.max_number,
};
break;
}
@@ -40,10 +31,10 @@ export default defineEventHandler(async () => {
});
}
outstandingChallenges.set(challenge.challenge, {
outstandingChallenges.set(challenge.salt, {
challenge, timeout: setTimeout(() => {
console.log("Challenge timed out:", challenge.challenge);
outstandingChallenges.delete(challenge.challenge);
console.log("Challenge timed out:", challenge.salt);
outstandingChallenges.delete(challenge.salt);
}, CHALLENGE_TIMEOUT_MS)
});

View File

@@ -4,42 +4,38 @@ import * as z from 'zod';
import { outstandingChallenges } from '~~/server/utils/pow';
const challengeSchema = z.object({
challenge: z.string().startsWith("s."),
solution: z.string().startsWith("s.")
challenge: z.string(),
nonce: z.string()
})
// post handler that takes in the challenge, and the nonce
export default defineEventHandler(async (event) => {
console.log(await readBody(event));
const body = await readValidatedBody(event, challengeSchema.safeParse);
if (!body.success) {
const errors = z.treeifyError(body.error);
const error_message = Object.entries(errors.errors).map(([key, value]) => `${key}: ${value}`).join('\n');
throw createError({
statusCode: 400,
statusMessage: 'Validation failed'
statusMessage: error_message
})
}
let { challenge, solution } = body.data;
let { challenge, nonce } = body.data;
const outstanding_challenge = outstandingChallenges.get(challenge);
if (outstanding_challenge === undefined) {
throw createError({
statusCode: 400,
statusMessage: 'Challenge not found'
})
}
const outstanding_challenge = outstandingChallenges.get(body.data.challenge)!;
// always delete the challenge on a solve attempt
clearTimeout(outstanding_challenge.timeout);
outstandingChallenges.delete(challenge);
// check if the challenge is valid
const challenge_valid = await validate_challenge(outstanding_challenge.challenge, solution);
console.log("CHALLENGE VALID", challenge_valid);
let challenge_valid = await validate_challenge(outstandingChallenges.get(challenge)!.challenge, {
challenge,
nonce,
});
if (challenge_valid) {
// clear the challenge
clearTimeout(outstandingChallenges.get(challenge)!.timeout);
outstandingChallenges.delete(challenge);
return {
message: 'Challenge solved'
};

View File

@@ -3,31 +3,26 @@ import { load } from 'js-toml';
import z from 'zod';
import { ChallengeStrategy } from "@impost/lib";
// const LeadingZeroesSchema = z.object({
// strategy: z.literal(ChallengeStrategy.LeadingZeroes),
// leading_zeroes: z.object({
// difficulty: z.number().int().min(1).max(64),
// }),
// });
// const TargetNumberSchema = z.object({
// strategy: z.literal(ChallengeStrategy.TargetNumber),
// target_number: z.object({
// max_number: z.number().int().min(1).max(100_000),
// }),
// });
const kCTFSchema = z.object({
strategy: z.literal(ChallengeStrategy.kCTF),
kctf: z.object({
difficulty: z.number().int().min(1),
const LeadingZeroesSchema = z.object({
strategy: z.literal(ChallengeStrategy.LeadingZeroes),
leading_zeroes: z.object({
difficulty: z.number().int().min(1).max(64),
}),
});
const TargetNumberSchema = z.object({
strategy: z.literal(ChallengeStrategy.TargetNumber),
target_number: z.object({
max_number: z.number().int().min(1).max(100_000),
}),
});
export type Config = z.infer<typeof Config>;
export const Config = z.discriminatedUnion('strategy', [
kCTFSchema,
LeadingZeroesSchema,
TargetNumberSchema,
]);
export let config: Config;

View File

@@ -1,30 +0,0 @@
wasm-opt-args := "--strip-debug --strip-dwarf --enable-tail-call --enable-bulk-memory -Oz"
zig-build-args := "--release=fast -Dtarget=wasm32-freestanding -Dcpu=generic+bulk_memory+bulk_memory_opt+simd128+tail_call"
npm-runner := "npm"
[working-directory: "example-app"]
playground: build
{{npm-runner}} run dev
build: build-widget
[working-directory: "solver"]
build-wasm:
zig build {{zig-build-args}}
wasm-opt {{wasm-opt-args}} --enable-simd -o zig-out/bin/solver.wasm zig-out/bin/solver.wasm
# The server does not support simd, so we disable it here
wasm-opt {{wasm-opt-args}} --disable-simd -o zig-out/bin/validator.wasm zig-out/bin/validator.wasm
[working-directory: "packages/lib"]
build-lib: build-wasm
{{npm-runner}} install
{{npm-runner}} link
{{npm-runner}} run build
[working-directory: "packages/widget"]
build-widget: build-lib
{{npm-runner}} install
{{npm-runner}} link @impost/lib
{{npm-runner}} link
{{npm-runner}} run build

17
package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "impost-monorepo",
"version": "0.0.0",
"private": true,
"scripts": {
"build": "pnpm build:all",
"build:all": "pnpm build:solver && pnpm build:lib && pnpm build:widget",
"build:solver": "pnpm --filter solver build",
"build:lib": "pnpm --filter lib build",
"build:widget": "pnpm --filter widget build",
"dev:example": "pnpm --filter example-app dev"
},
"devDependencies": {
"typescript": "^5.0.0"
},
"packageManager": "pnpm@10.21.0+sha512.da3337267e400fdd3d479a6c68079ac6db01d8ca4f67572083e722775a796788a7a9956613749e000fac20d424b594f7a791a5f4e2e13581c5ef947f26968a40"
}

View File

@@ -36,4 +36,4 @@
"dependencies": {
"uuidv7": "^1.0.2"
}
}
}

View File

@@ -1,44 +1,28 @@
import { UUID } from "uuidv7";
export enum ChallengeAlgorithm {
Argon2id = "argon2id",
}
export enum ChallengeStrategy {
kCTF = "kctf",
LeadingZeroes = "leading_zeroes",
TargetNumber = "target_number",
}
// In this case, the client will repeatedly hash a number with has until it
// finds a hash thaat starts with *difficulty* leading zeroes
// export interface ChallengeLeadingZeroes {
// algorithm: ChallengeAlgorithm;
// strategy: ChallengeStrategy.LeadingZeroes;
// salt: string; // random string
// difficulty: number;
// }
// // In this case, the server generates a random number, and the client will hash
// // the salt (a random string) + a random number until it finds a hash that is equal to challenge
// export interface ChallengeTargetNumber {
// algorithm: ChallengeAlgorithm;
// strategy: ChallengeStrategy.TargetNumber;
// salt: string; // random string
// target: string; // hash of salt + random number
// }
export interface InnerChallengekCTF {
strategy: ChallengeStrategy.kCTF;
salt: UUID; // UUIDv7
export interface ChallengeLeadingZeroes {
algorithm: ChallengeAlgorithm;
strategy: ChallengeStrategy.LeadingZeroes;
salt: string; // random string
difficulty: number;
}
export interface ChallengekCTF {
strategy: ChallengeStrategy.kCTF;
challenge: string;
// In this case, the server generates a random number, and the client will hash
// the salt (a random string) + a random number until it finds a hash that is equal to challenge
export interface ChallengeTargetNumber {
algorithm: ChallengeAlgorithm;
strategy: ChallengeStrategy.TargetNumber;
salt: string; // random string
target: string; // hash of salt + random number
}
export type InnerChallenge = InnerChallengekCTF;
export type Challenge = ChallengekCTF;
export type Challenge = ChallengeLeadingZeroes | ChallengeTargetNumber;

View File

@@ -1,12 +1,11 @@
import WASMSolverUrl from '../../../solver/zig-out/bin/solver.wasm?url&inline';
import WASMSolverUrl from '../../solver/zig-out/bin/solver.wasm?url&inline';
import { ChallengeStrategy, type Challenge, type ChallengeLeadingZeroes, type ChallengeTargetNumber } from '.';
type WasmExports = Record<string, Function> & {
"malloc": (byte_count: number) => number | null;
"free": (ptr: number | null, byte_count: number) => void;
// "solve_leaading_zeroes_challenge": (challenge_ptr: number, challenge_len: number, difficulty: number) => number;
// "solve_target_number_challenge": (challenge_ptr: number, challenge_len: number, target_ptr: number, target_len:
// number) => number;
"solve": (value_ptr: number, value_len: number) => number,
"solve_leaading_zeroes_challenge": (challenge_ptr: number, challenge_len: number, difficulty: number) => number;
"solve_target_number_challenge": (challenge_ptr: number, challenge_len: number, target_ptr: number, target_len: number) => number;
"memory": WebAssembly.Memory;
}
@@ -32,35 +31,75 @@ export async function init_solver(env: SolverEnv, module: WebAssembly.Module): P
}) as unknown as SolverModule;
}
export function solve(solver: SolverModule, challenge: string): string {
console.log(challenge);
export function solve_challenge(solver: SolverModule, challenge: Challenge): number {
switch (challenge.strategy) {
case ChallengeStrategy.LeadingZeroes:
return solve_leaading_zeroes_challenge(solver, challenge);
case ChallengeStrategy.TargetNumber:
return solve_target_number_challenge(solver, challenge);
default:
throw new Error("Invalid challenge strategy");
}
}
function solve_leaading_zeroes_challenge(solver: SolverModule, challenge: ChallengeLeadingZeroes): number {
const { salt, difficulty } = challenge;
const encoder = new TextEncoder();
const challenge_buf = encoder.encode(challenge);
const challenge_ptr = solver.exports.malloc(challenge_buf.length);
if (challenge_ptr === 0 || challenge_ptr === null) {
const salt_bytes = encoder.encode(salt);
const salt_ptr = solver.exports.malloc(salt_bytes.length);
if (salt_ptr === 0 || salt_ptr === null) {
throw new Error("Failed to allocate memory for challenge string");
}
const memory = new Uint8Array(solver.exports.memory.buffer);
memory.set(challenge_buf, challenge_ptr);
memory.set(salt_bytes, salt_ptr);
const ret = solver.exports.solve(challenge_ptr, challenge_buf.length);
const ret = solver.exports.solve_leaading_zeroes_challenge(
salt_ptr,
salt_bytes.length,
difficulty,
);
console.log("RET", ret);
if (ret <= 0) {
if (ret < 0) {
throw new Error("Failed to solve challenge");
}
const length = new DataView(solver.exports.memory.buffer, ret, 2).getUint16(0, true);
const solution = new TextDecoder().decode(solver.exports.memory.buffer.slice(ret + 2, ret + 2 + length));
console.log("SOLUTION", solution);
console.log("LENGTH", length);
solver.exports.free(ret, 2 + length);
return solution;
return ret;
}
function solve_target_number_challenge(solver: SolverModule, challenge: ChallengeTargetNumber): number {
const { salt, target } = challenge;
const encoder = new TextEncoder();
const salt_bytes = encoder.encode(salt);
const target_bytes = encoder.encode(target);
const salt_ptr = solver.exports.malloc(salt_bytes.length);
if (salt_ptr === 0 || salt_ptr === null) {
throw new Error("Failed to allocate memory for salt string");
}
const target_ptr = solver.exports.malloc(target_bytes.length);
if (target_ptr === 0 || target_ptr === null) {
throw new Error("Failed to allocate memory for target string");
}
const memory = new Uint8Array(solver.exports.memory.buffer);
memory.set(salt_bytes, salt_ptr);
memory.set(target_bytes, target_ptr);
const ret = solver.exports.solve_target_number_challenge(
target_ptr,
target_bytes.length,
salt_ptr,
salt_bytes.length,
);
if (ret < 0) {
throw new Error("Failed to solve challenge");
}
return ret;
}

View File

@@ -1,11 +1,12 @@
import { ChallengeStrategy, type Challenge, type InnerChallenge } from '.';
import WASMValidatorUrl from '../../../solver/zig-out/bin/validator.wasm?url&inline';
import { uuidv7obj } from 'uuidv7';
import { ChallengeAlgorithm, ChallengeStrategy, type Challenge, type ChallengeLeadingZeroes, type ChallengeTargetNumber } from '.';
import WASMValidatorUrl from '../../solver/zig-out/bin/validator.wasm?url&inline';
type WasmExports = Record<string, Function> & {
"malloc": (byte_count: number) => number | null;
"free": (ptr: number | null, byte_count: number) => void;
"validate": (challenge_ptr: number, challenge_len: number, solution_ptr: number, solution_len: number) => boolean;
"validate_leading_zeroes_challenge": (challenge_ptr: number, challenge_len: number, nonce_ptr: number, nonce_len: number, difficulty: number) => number;
"validate_target_number_challenge": (target_ptr: number, target_len: number, nonce_ptr: number, nonce_len: number, salt_ptr: number, salt_len: number) => number;
"hash": (challenge_ptr: number, challenge_len: number, nonce_ptr: number, nonce_len: number) => bigint;
"memory": WebAssembly.Memory;
}
@@ -13,92 +14,159 @@ export interface ValidatorModule extends WebAssembly.Instance {
exports: WasmExports;
}
function array_to_base64(buffer: ArrayBufferLike): string {
let binary = '';
const bytes = new Uint8Array(buffer);
for (var i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
function array_to_base64(buffer: ArrayBuffer): string {
return btoa(String.fromCharCode(...new Uint8Array(buffer)));
}
export interface kCTFChallengeConfig {
parameters: Object;
strategy: ChallengeStrategy.kCTF;
difficulty: number;
}
export type ChallengeConfig = kCTFChallengeConfig;
const VERSION = "s";
async function encode_challenge(challenge: InnerChallenge, parameters: Object = {}): Promise<string> {
if (challenge.strategy !== ChallengeStrategy.kCTF) {
throw new Error("Unsupported challenge strategy");
}
if (challenge.difficulty < 1) {
throw new Error("Difficulty must be at least 1");
}
const difficulty_buf = new Uint8Array(4);
const view = new DataView(difficulty_buf.buffer);
view.setUint32(0, challenge.difficulty, false);
const difficulty_str = array_to_base64(difficulty_buf.buffer);
const salt_str = array_to_base64(challenge.salt.bytes.buffer);
// the parameters str is expected to be sliced out of the challenge via the widget before it sends it to the wasm solver.
async function generate_leading_zeroes_challenge(parameters: Object, difficulty: number): Promise<ChallengeLeadingZeroes> {
let parameters_str = Object.entries(parameters).map(([key, value]) => `${key}=${value}`).join("&");
if (parameters_str.length > 0) {
parameters_str = "?" + parameters_str;
}
let salt = `${array_to_base64(crypto.getRandomValues(new Uint8Array(32)).buffer)}?${parameters_str}`;
return `${VERSION}.${difficulty_str}.${salt_str}${parameters_str}`;
}
export async function generate_challenge(config: ChallengeConfig): Promise<Challenge | null> {
if (config.difficulty < 1) {
throw new Error("Difficulty must be at least 1");
}
const challenge: InnerChallenge = {
strategy: ChallengeStrategy.kCTF,
salt: uuidv7obj(),
difficulty: config.difficulty,
}
return {
strategy: ChallengeStrategy.kCTF,
challenge: await encode_challenge(challenge),
let challenge: ChallengeLeadingZeroes = {
algorithm: ChallengeAlgorithm.Argon2id,
strategy: ChallengeStrategy.LeadingZeroes,
salt,
difficulty,
};
return challenge;
}
export async function validate_challenge(challenge: Challenge, challenge_solution: string): Promise<boolean> {
const validator = (await WebAssembly.instantiateStreaming(fetch(WASMValidatorUrl), {
env: {
__log: (str_ptr: number, str_len: number) => console.log(new TextDecoder().decode(new Uint8Array(validator.exports.memory.buffer, str_ptr, str_len))),
}
})).instance as unknown as ValidatorModule
async function generate_target_number_challenge(parameters: Object, max_number: number): Promise<ChallengeTargetNumber | null> {
// in target number config, since we need to generate a target hash, we
// need to hash the salt + nonce, so the client knows what the target is
const validator = (await WebAssembly.instantiateStreaming(fetch(WASMValidatorUrl))).instance as unknown as ValidatorModule;
let parameters_str = Object.entries(parameters).map(([key, value]) => `${key}=${value}`).join("&");
let salt = `${array_to_base64(crypto.getRandomValues(new Uint8Array(32)).buffer)}?${parameters_str}`;
let random_number = new DataView(crypto.getRandomValues(new Uint8Array(4)).buffer).getUint32(0, true) % max_number;
const encoder = new TextEncoder();
const challenge_buf = encoder.encode(challenge.challenge);
const solution_buf = encoder.encode(challenge_solution);
const salt_bytes = encoder.encode(salt);
const random_number_bytes = encoder.encode(random_number.toString());
const challenge_ptr = validator.exports.malloc(challenge_buf.length);
const solution_ptr = validator.exports.malloc(solution_buf.length);
const salt_ptr = validator.exports.malloc(salt_bytes.length);
const random_number_ptr = validator.exports.malloc(random_number_bytes.length);
if (challenge_ptr === 0 || challenge_ptr === null || solution_ptr === 0 || solution_ptr === null) {
if (salt_ptr === 0 || salt_ptr === null || random_number_ptr === 0 || random_number_ptr === null) {
console.error("Failed to allocate memory for challenge string");
return false;
return null;
}
const memory = new Uint8Array(validator.exports.memory.buffer);
memory.set(challenge_buf, challenge_ptr);
memory.set(solution_buf, solution_ptr);
memory.set(salt_bytes, salt_ptr);
memory.set(random_number_bytes, random_number_ptr);
const is_valid = validator.exports.validate(challenge_ptr, challenge.challenge.length, solution_ptr, challenge_solution.length);
validator.exports.free(challenge_ptr, challenge.challenge.length);
validator.exports.free(solution_ptr, challenge_solution.length);
let target_blob: bigint = validator.exports.hash(salt_ptr, salt_bytes.length, random_number_ptr, random_number_bytes.length);
let target_ptr = Number(target_blob & BigInt(0xFFFFFFFF));
let target_len = Number(target_blob >> BigInt(32));
return is_valid;
validator.exports.free(salt_ptr, salt_bytes.length);
validator.exports.free(random_number_ptr, random_number_bytes.length);
// do NOT use `memory` here, by this time it has almost definitely been resized and will cause errors to touch
let target_slice = new Uint8Array(validator.exports.memory.buffer.slice(target_ptr, target_ptr + target_len));
const target = new TextDecoder().decode(target_slice);
let challenge: ChallengeTargetNumber = {
algorithm: ChallengeAlgorithm.Argon2id,
strategy: ChallengeStrategy.TargetNumber,
salt,
target
};
return challenge;
}
export interface LeadingZeroesChallengeConfig {
parameters: Object;
strategy: ChallengeStrategy.LeadingZeroes;
difficulty: number;
}
export interface TargetNumberChallengeConfig {
parameters: Object;
strategy: ChallengeStrategy.TargetNumber;
max_number: number;
}
export type ChallengeConfig = LeadingZeroesChallengeConfig | TargetNumberChallengeConfig;
export async function generate_challenge(config: ChallengeConfig): Promise<Challenge | null> {
let challenge: Challenge | null = null;
switch (config.strategy) {
case ChallengeStrategy.LeadingZeroes:
challenge = await generate_leading_zeroes_challenge(config.parameters, config.difficulty);
break;
case ChallengeStrategy.TargetNumber:
challenge = await generate_target_number_challenge(config.parameters, config.max_number);
break;
}
if (challenge === null) {
return null;
}
return challenge;
}
export async function validate_challenge(challenge: Challenge, challenge_solution: { challenge: string, nonce: string }): Promise<boolean> {
const validator = (await WebAssembly.instantiateStreaming(fetch(WASMValidatorUrl))).instance as unknown as ValidatorModule
const encoder = new TextEncoder();
let err;
let memory;
let nonce_bytes, nonce_ptr;
let target_bytes, target_ptr;
switch (challenge.strategy) {
case ChallengeStrategy.LeadingZeroes:
target_bytes = encoder.encode(challenge_solution.challenge);
nonce_bytes = encoder.encode(challenge_solution.nonce);
target_ptr = validator.exports.malloc(challenge_solution.challenge.length);
nonce_ptr = validator.exports.malloc(challenge_solution.nonce.length);
if (target_ptr === 0 || target_ptr === null || nonce_ptr === 0 || nonce_ptr === null) {
console.error("Failed to allocate memory for challenge string");
return false;
}
memory = new Uint8Array(validator.exports.memory.buffer);
memory.set(target_bytes, target_ptr);
memory.set(nonce_bytes, nonce_ptr);
err = validator.exports.validate_leading_zeroes_challenge(target_ptr, target_bytes.length, nonce_ptr, nonce_bytes.length, challenge.difficulty);
validator.exports.free(target_ptr, target_bytes.length);
validator.exports.free(nonce_ptr, nonce_bytes.length);
break;
case ChallengeStrategy.TargetNumber:
target_bytes = encoder.encode(challenge.target);
const salt_bytes = encoder.encode(challenge.salt);
nonce_bytes = encoder.encode(challenge_solution.nonce);
const salt_ptr = validator.exports.malloc(salt_bytes.length);
target_ptr = validator.exports.malloc(target_bytes.length);
nonce_ptr = validator.exports.malloc(nonce_bytes.length);
if (salt_ptr === 0 || salt_ptr === null || target_ptr === 0 || target_ptr === null || nonce_ptr === 0 || nonce_ptr === null) {
console.error("Failed to allocate memory for challenge string");
return false;
}
memory = new Uint8Array(validator.exports.memory.buffer);
memory.set(salt_bytes, salt_ptr);
memory.set(target_bytes, target_ptr);
memory.set(nonce_bytes, nonce_ptr);
err = validator.exports.validate_target_number_challenge(target_ptr, target_bytes.length, nonce_ptr, nonce_bytes.length, salt_ptr, salt_bytes.length);
validator.exports.free(salt_ptr, salt_bytes.length);
validator.exports.free(target_ptr, target_bytes.length);
validator.exports.free(nonce_ptr, nonce_bytes.length);
break;
}
return err === 0;
}

View File

@@ -18,7 +18,7 @@ export default defineConfig({
preserveModules: false
}
},
sourcemap: true
minify: true
},
resolve: {
alias: {

View File

@@ -0,0 +1,11 @@
{
"name": "solver",
"description": "Zig WASM POW solver, not an actual node package, just using this so that pnpm will build it for me",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "pnpm run build:wasm && pnpm run minify",
"build:wasm": "zig build --release=fast -Dtarget=wasm32-freestanding -Dcpu=generic+bulk_memory+bulk_memory_opt+simd128+tail_call",
"minify": "wasm-opt --strip-debug --strip-dwarf -O4 -o zig-out/bin/solver.wasm zig-out/bin/solver.wasm && wasm-opt --strip-debug --strip-dwarf -O4 -o zig-out/bin/validator.wasm zig-out/bin/validator.wasm"
}
}

View File

@@ -2,8 +2,8 @@ const std = @import("std");
const Allocator = std.mem.Allocator;
var argon2_params = std.crypto.pwhash.argon2.Params{
.t = 4, // time cost
.m = 256, // memory cost (in KiB)
.t = 3, // time cost
.m = 8192, // memory cost (in KiB)
.p = 1, // parallelism (this doesnt do anything because we are targeting wasm, and we do multithreading differently anyways)
};
@@ -11,21 +11,8 @@ const dk_len: usize = 32; // 16 or 32 byte key
var derived: [dk_len]u8 = undefined;
var buffer_hash_hex: [64]u8 = undefined;
fn bytesToHex(bytes: []const u8, output: []u8) void {
const hex_chars = "0123456789abcdef";
var i: usize = 0;
while (i < bytes.len) : (i += 1) {
output[i * 2] = hex_chars[(bytes[i] >> 4)];
output[i * 2 + 1] = hex_chars[bytes[i] & 0x0F];
}
}
pub fn hash(allocator: Allocator, challenge: []const u8, nonce: []const u8) ![]u8 {
try std.crypto.pwhash.argon2.kdf(allocator, &derived, nonce, challenge, argon2_params, .argon2id);
var hash_bytes: [32]u8 = undefined;
std.crypto.hash.sha2.Sha256.hash(&derived, @ptrCast(hash_bytes[0..].ptr), .{});
bytesToHex(&hash_bytes, &buffer_hash_hex);
return buffer_hash_hex[0..];
return derived[0..];
}

View File

@@ -0,0 +1,155 @@
const std = @import("std");
const builtin = @import("builtin");
const Allocator = std.mem.Allocator;
const utils = @import("utils.zig");
const argon2 = @import("argon2.zig");
var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init;
var allocator = gpa.allocator();
extern fn __get_solution() i32;
extern fn __set_solution(value: i32) void;
extern fn __cmpxchg_solution(old: i32, new: i32) i32;
extern fn __fetch_add_nonce(value: i32) i32;
extern fn __log(str_ptr: usize, str_len: usize) void;
fn log(comptime level: std.log.Level, comptime scope: @TypeOf(.EnumLiteral), comptime fmt: []const u8, args: anytype) void {
if (comptime builtin.target.cpu.arch != .wasm32) {
std.log.defaultLog(level, scope, fmt, args);
return;
}
const log_level_str = switch (level) {
.err => "Error: ",
.warn => "Warning: ",
.info => "Info: ",
.debug => "Debug: ",
};
const formatted = std.fmt.allocPrint(allocator, fmt, args) catch return;
const log_str = std.fmt.allocPrint(allocator, "{s}{s}", .{ log_level_str, formatted }) catch return;
allocator.free(formatted);
__log(@intFromPtr(log_str.ptr), log_str.len);
allocator.free(log_str);
}
pub const std_options: std.Options = .{ .logFn = log };
var hex_encoder = utils.HexEncoder{};
export fn malloc(byte_count: usize) ?*u8 {
const ptr = allocator.alloc(u8, byte_count) catch return null;
return @ptrCast(ptr.ptr);
}
export fn free(ptr: ?*anyopaque, byte_count: usize) void {
if (ptr) |p| {
const cast_ptr: [*]u8 = @ptrCast(p);
allocator.free(cast_ptr[0..byte_count]);
}
}
// returns nonce on success, -1 on failure
// to get the error, call get_solve_error
export fn solve_leaading_zeroes_challenge(challenge_ptr: [*]u8, challenge_len: usize, difficulty: u32) i32 {
const challenge_slice = challenge_ptr[0..challenge_len];
if (difficulty < 1 or difficulty > 64) {
std.log.err("Invalid difficulty for leading zeroes\n", .{});
return -1;
}
const max_nonce_iterations: u64 = 1_000_000_000;
// 64 + 9 digits for nonce since the max nonce is 999_999_999 (not 1 billion since nonce < max_nonce_iterations)
var input_buffer: []u8 = allocator.alloc(u8, challenge_len + 9) catch {
std.log.err("Failed to allocate memory for challenge\n", .{});
return -1;
};
// dont leak memory :pepega:
defer allocator.free(input_buffer);
@memcpy(input_buffer[0..challenge_len], challenge_slice);
var nonce = __fetch_add_nonce(1);
while (nonce < max_nonce_iterations) : (nonce = __fetch_add_nonce(1)) {
if (__get_solution() != -1) {
// solution has already been found, no point in continuing
return 0;
}
const nonce_str = std.fmt.bufPrint(input_buffer[challenge_len..], "{d}", .{nonce}) catch {
std.log.err("Failed to allocate memory for nonce\n", .{});
return -1;
};
const argon2_key = argon2.hash(allocator, input_buffer[0..challenge_len], input_buffer[challenge_len .. challenge_len + nonce_str.len]) catch {
std.log.err("Failed to hash argon2 key\n", .{});
return -1;
};
_ = hex_encoder.encode(argon2_key);
if (!hex_encoder.countZeroes(difficulty)) {
continue;
}
// Found a solution!
if (__cmpxchg_solution(-1, nonce) == -1) {
// we found a solution, and we are the first to do so
return nonce;
} else {
// we found a solution, but we are not the first to do so
return 0;
}
}
return -1;
}
export fn solve_target_number_challenge(target_ptr: [*]u8, target_len: usize, salt_ptr: [*]u8, salt_len: usize) i32 {
const target_slice = target_ptr[0..target_len];
const salt_slice = salt_ptr[0..salt_len];
// TODO: take in max number
const max_nonce_iterations: usize = 1_000_000_000;
const max_digits = std.math.log10(max_nonce_iterations);
var input_buffer: []u8 = allocator.alloc(u8, salt_len + max_digits) catch {
return -1;
};
defer allocator.free(input_buffer);
@memcpy(input_buffer[0..salt_len], salt_slice);
var nonce = __fetch_add_nonce(1);
while (nonce < max_nonce_iterations) : (nonce = __fetch_add_nonce(1)) {
if (__get_solution() != -1) {
return 0;
}
const nonce_str = std.fmt.bufPrint(input_buffer[salt_len..], "{d}", .{nonce}) catch {
return -1;
};
const argon2_key = argon2.hash(allocator, input_buffer[0..salt_len], input_buffer[salt_len .. salt_len + nonce_str.len]) catch {
return -1;
};
const hex_slice = hex_encoder.encode(argon2_key);
if (std.mem.eql(u8, target_slice, hex_slice)) {
// Found a solution!
if (__cmpxchg_solution(-1, nonce) == -1) {
// we found a solution, and we are the first to do so
return nonce;
} else {
// we found a solution, but we are not the first to do so
return 0;
}
}
}
return -1;
}

View File

@@ -0,0 +1,43 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
pub const HexEncoder = struct {
scratch: [64]u8 = undefined,
scratch_set: bool = false,
const Self = @This();
pub fn encode(self: *Self, bytes: []const u8) []u8 {
self.scratch_set = true;
const hex_chars = "0123456789abcdef";
var i: usize = 0;
while (i < bytes.len) : (i += 1) {
self.scratch[i * 2] = hex_chars[(bytes[i] >> 4)];
self.scratch[i * 2 + 1] = hex_chars[bytes[i] & 0x0F];
}
return &self.scratch;
}
// counts the number of leading hexidecimal zeroes in the scratch buffer
// which is set by encode
pub fn countZeroes(self: *Self, zeroes: usize) bool {
if (!self.scratch_set) {
return false;
}
if (zeroes > 64 or zeroes == 0) {
return false;
}
var i: usize = 0;
while (i < zeroes) : (i += 1) {
if (self.scratch[i] != '0') {
return false;
}
}
return true;
}
};

View File

@@ -0,0 +1,76 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const argon2 = @import("argon2.zig");
const utils = @import("utils.zig");
var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init;
var allocator = gpa.allocator();
var hex_encoder = utils.HexEncoder{};
export fn malloc(byte_count: usize) ?*u8 {
const ptr = allocator.alloc(u8, byte_count) catch return null;
return @ptrCast(ptr.ptr);
}
export fn free(ptr: ?*anyopaque, byte_count: usize) void {
if (ptr) |p| {
const cast_ptr: [*]u8 = @ptrCast(p);
allocator.free(cast_ptr[0..byte_count]);
}
}
export fn validate_leading_zeroes_challenge(challenge_ptr: [*]u8, challenge_len: usize, nonce_ptr: [*]u8, nonce_len: usize, difficulty: u32) i32 {
const challenge_slice = challenge_ptr[0..challenge_len];
const nonce_slice = nonce_ptr[0..nonce_len];
if (difficulty < 1 or difficulty > 64) {
return -1;
}
const argon2_key = argon2.hash(allocator, challenge_slice, nonce_slice) catch return -2;
_ = hex_encoder.encode(argon2_key);
if (!hex_encoder.countZeroes(difficulty)) {
return -3;
}
return 0;
}
export fn validate_target_number_challenge(target_ptr: [*]u8, target_len: usize, nonce_ptr: [*]u8, nonce_len: usize, salt_ptr: [*]u8, salt_len: usize) i32 {
const target_slice = target_ptr[0..target_len];
const salt_slice = salt_ptr[0..salt_len];
const nonce_slice = nonce_ptr[0..nonce_len];
const argon2_key = argon2.hash(allocator, salt_slice, nonce_slice) catch return -2;
const hex_slice = hex_encoder.encode(argon2_key);
if (!std.mem.eql(u8, target_slice, hex_slice)) {
return -3;
}
return 0;
}
export fn hash(challenge_ptr: [*]u8, challenge_len: usize, nonce_ptr: [*]u8, nonce_len: usize) u64 {
const challenge = challenge_ptr[0..challenge_len];
const nonce = nonce_ptr[0..nonce_len];
const argon2_key = argon2.hash(allocator, challenge, nonce) catch return 0;
const hex_slice = hex_encoder.encode(argon2_key);
// bs to get the compiler to not whine about hash_slice.len being a u5 annd thus cannot be shifted by 32
var ret: u64 = hex_slice.len;
ret <<= 32;
ret |= @intFromPtr(hex_slice.ptr);
return ret;
}
// pub fn main() void {
// const challenge = "4d7220e22a1ea588fea60000ab8874194e4c6ffd71077adbae915826c73dbf48";
// const nonce = "4302";
// const difficulty = 3;
// std.log.info("{d}", .{validate_challenge(@constCast(challenge[0..].ptr), challenge.len, @constCast(nonce[0..].ptr), nonce.len, difficulty)});
// }

View File

@@ -8,6 +8,9 @@
"name": "@impost/widget",
"version": "0.1.0",
"license": "BSL-1.0",
"dependencies": {
"comlink": "^4.4.2"
},
"devDependencies": {
"@types/node": "^20.11.24",
"lit": "^3.1.2",
@@ -1654,6 +1657,11 @@
"dev": true,
"license": "MIT"
},
"node_modules/comlink": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/comlink/-/comlink-4.4.2.tgz",
"integrity": "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g=="
},
"node_modules/compare-versions": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz",

View File

@@ -23,6 +23,7 @@
"lit-element": "^3.1.2"
},
"devDependencies": {
"@impost/lib": "workspace:*",
"@types/node": "^20.11.24",
"lit": "^3.1.2",
"lit-element": "^3.1.2",
@@ -30,6 +31,7 @@
"tslib": "^2.6.2",
"typescript": "^5.3.3",
"vite": "npm:rolldown-vite@latest",
"vite-plugin-dts": "^4.5.4"
"vite-plugin-dts": "^4.5.4",
"comlink": "^4.4.2"
}
}

View File

@@ -1,13 +1,15 @@
import { LitElement, html, css, isServer, type PropertyValues } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { type ChallengeSolveRequest, type SolutionMessage, WorkerMessageType, type WorkerRequest, WorkerResponseType } from './types/worker';
import { type Challenge, ChallengeStrategy } from '@impost/lib';
import { type Challenge } from '@impost/lib';
import { get_wasm_module } from '@impost/lib/solver';
import * as Comlink from 'comlink';
import ChallengeWorker from './solver-worker?worker&inline';
import SolverWorker from './solver-worker?worker&inline';
@customElement('pow-captcha')
export class PowCaptcha extends LitElement {
type SolverWorkerAPI = Comlink.Remote<typeof import("./solver-worker")>;
@customElement('impost-captcha')
export class ImpostCaptcha extends LitElement {
static override styles = css`
:host {
display: block;
@@ -21,9 +23,6 @@ export class PowCaptcha extends LitElement {
background-color: var(--impost-widget-background-color, #070408);
border-radius: var(--impost-widget-border-radius, 8px);
}
.impost-error-icon {
color: var(--impost-widget-error-icon-color, #FF8117);
}
.impost-main {
display: flex;
align-items: center;
@@ -66,13 +65,13 @@ export class PowCaptcha extends LitElement {
.impost-footer div a:hover {
text-decoration: none;
}
.hidden {
display: none;
}
`;
/// ================================================
/// Configuration
/// ================================================
// one of: "load", "focus", "submit", "off"
@property({ type: String })
auto: "onload" | "onfocus" | "onsubmit" | "off" = "off";
@@ -82,12 +81,22 @@ export class PowCaptcha extends LitElement {
@property({ type: String })
challengejson: string = '';
@property({ type: Boolean })
showHashrate: boolean = false;
/// ================================================
/// Internals
/// ================================================
// needed to allow for multiple widgets on the same page if you wanted to
// do that for some reason
uid: string = Math.floor(Math.random() * 100000).toString();
private uid: string = Math.floor(Math.random() * 100000).toString();
private _internals: ElementInternals | null = null;
static formAssociated = true;
@state()
private solution: string = '';
private solution: string | null = null;
@state()
private challengeData: Challenge | null = null;
@@ -98,15 +107,26 @@ export class PowCaptcha extends LitElement {
@state()
private disabled: boolean = true;
@state()
private hashRate: number = 0;
// stores the nonce and solution atomics
private sab: SharedArrayBuffer = new SharedArrayBuffer(8);
private solverWorkers: Worker[] | null = null;
private solverWorkers: SolverWorkerAPI[] | null = null;
private nativeWorkers: Worker[] | null = null;
private solveStartTime: number | null = null;
private hashRateInterval: number | null = null;
override connectedCallback() {
super.connectedCallback();
this._internals = this.attachInternals();
this.fetchChallenge();
console.log(this._internals.form);
this.initWorkers();
switch (this.auto) {
@@ -118,7 +138,23 @@ export class PowCaptcha extends LitElement {
break;
case 'onsubmit':
if (this.parentElement?.nodeName === 'FORM') {
this.parentElement.addEventListener('submit', () => this.solveChallenge());
this.parentElement.addEventListener('submit', async (ev) => {
if (this.status === 'solved') {
return;
}
ev.preventDefault();
await this.solveChallenge();
const form = this.parentElement as HTMLFormElement;
if (form.requestSubmit) {
form.requestSubmit();
} else {
form.submit();
}
});
}
break;
}
@@ -132,10 +168,15 @@ export class PowCaptcha extends LitElement {
override disconnectedCallback() {
super.disconnectedCallback();
if (this.hashRateInterval !== null) {
clearInterval(this.hashRateInterval);
this.hashRateInterval = null;
}
for (const worker of this.solverWorkers || []) {
for (const worker of this.nativeWorkers || []) {
worker.terminate();
this.solverWorkers = null;
this.nativeWorkers = null;
}
}
@@ -154,6 +195,7 @@ export class PowCaptcha extends LitElement {
return;
}
// challenge data must be provided by the user when using SSR
if (isServer) {
return;
}
@@ -165,16 +207,21 @@ export class PowCaptcha extends LitElement {
})
.catch(error => {
console.error('Error fetching challenge:', error);
console.error('Failed to fetch challenge');
this.status = 'error';
});
}
async initWorkers() {
this.solverWorkers = [];
this.nativeWorkers = [];
const num_workers = 1;
const num_workers = navigator.hardwareConcurrency;
for (let i = 0; i < num_workers; i++) {
this.solverWorkers.push(new ChallengeWorker());
const nativeWorker = new SolverWorker();
const comlinkWorker = Comlink.wrap<SolverWorkerAPI>(nativeWorker);
this.nativeWorkers.push(nativeWorker);
this.solverWorkers.push(comlinkWorker);
}
const atomics_view = new Int32Array(this.sab);
@@ -184,213 +231,120 @@ export class PowCaptcha extends LitElement {
let wasm_module = await get_wasm_module();
let worker_promises: Promise<void>[] = [];
for (let i = 0; i < this.solverWorkers.length; i++) {
console.log('Worker', i);
const worker = this.solverWorkers[i]!;
worker_promises.push(new Promise<void>((resolve, reject) => {
const message_handler = (event: MessageEvent<SolutionMessage>) => {
console.log('Worker', i, 'got message', event.data);
if (event.data.type === WorkerResponseType.Error) {
console.error("Worker error:", event.data.error);
reject(event.data.error);
}
if (event.data.type === WorkerResponseType.InitOk) {
resolve();
}
reject(new Error("Unexpected message from worker"));
};
const error_handler = (error: ErrorEvent) => {
console.error("Worker error:", error);
reject(error);
};
worker.addEventListener('message', message_handler);
worker.addEventListener('error', error_handler);
worker.postMessage({
type: WorkerMessageType.Init,
module: wasm_module,
sab: this.sab,
} as WorkerRequest);
}));
const solver = this.solverWorkers[i]!;
worker_promises.push(solver.init(wasm_module, this.sab)); // Direct call to exposed `init` method
}
const timeoutMs = 10 * 1000;
let timeout: number;
const timeoutPromise = new Promise((_, reject) => {
timeout = setTimeout(() => {
console.error('Failed to initialize workers in time');
this.status = 'error';
reject(new Error(`Function timed out after ${timeoutMs}ms`));
reject(new Error(`Function timeout after ${timeoutMs}ms`));
}, timeoutMs);
});
await Promise.race([
Promise.allSettled(worker_promises).then(() => {
Promise.allSettled(worker_promises).then(results => {
const failedInits = results.filter(r => r.status === 'rejected');
if (failedInits.length > 0) {
console.error('Some workers failed to initialize:', failedInits);
// we might want to collect all errors, and if every
// worker fails, we can throw, but carry on if only some
// fail. For now, we'll just throw if any fail.
throw new Error("One or more workers failed to initialize.");
}
console.log('All workers initialized');
return;
}),
timeoutPromise,
]).then(() => {
clearTimeout(timeout);
});
}
async issueChallengeToWorker(worker: Worker, request: ChallengeSolveRequest): Promise<SolutionMessage> {
return new Promise<SolutionMessage>((resolve, reject) => {
const message_handler = (event: MessageEvent<SolutionMessage>) => {
worker.removeEventListener('message', message_handler);
worker.removeEventListener('error', error_handler);
resolve(event.data);
};
const error_handler = (error: ErrorEvent) => {
worker.removeEventListener('error', error_handler);
worker.removeEventListener('message', message_handler);
console.error("Worker error:", error);
reject(error);
};
worker.addEventListener('message', message_handler);
worker.addEventListener('error', error_handler);
// switch (request.strategy) {
// case ChallengeStrategy.LeadingZeroes:
// worker.postMessage({
// strategy: ChallengeStrategy.LeadingZeroes,
// salt: request.salt,
// difficulty: request.difficulty,
// } as WorkerRequest);
// break;
// case ChallengeStrategy.TargetNumber:
// worker.postMessage({
// strategy: ChallengeStrategy.TargetNumber,
// target: request.target,
// salt: request.salt,
// } as WorkerRequest);
// break;
// }
switch (request.strategy) {
case ChallengeStrategy.kCTF:
worker.postMessage({
strategy: ChallengeStrategy.kCTF,
challenge: request.challenge,
} as WorkerRequest);
break;
}
}).catch(error => {
clearTimeout(timeout);
console.error("Worker initialization failed:", error);
this.status = 'error';
throw error;
});
}
async solveChallenge() {
if (!this.challengeData || this.solverWorkers === null) {
console.error('solveChallenge called before challenge is ready');
// in all normal cases, this should be impossible
this.status = 'error';
return;
}
if (this.solution !== '') {
if (this.solution !== null || this.status !== 'unsolved') {
// do not solve twice
return;
}
this.solveStartTime = performance.now();
this.hashRateInterval = setInterval(async () => {
const nonce = this.getCurrentWorkingNonce();
this.hashRate = (nonce / ((performance.now() - this.solveStartTime!) / 1000));
console.log(this.hashRate);
}, 250);
this.dispatchEvent(new CustomEvent('impost:solve', {
detail: { challenge: this.challengeData, }, // empty solution
bubbles: true,
composed: true,
}))
console.log(this.challengeData);
}));
this.status = 'solving';
this.solution = '';
this.solution = null;
const atomics_view = new Int32Array(this.sab);
// reset atomics
Atomics.store(atomics_view, 0, 0);
Atomics.store(atomics_view, 1, -1);
let request: ChallengeSolveRequest;
// switch (this.challengeData.strategy) {
// case ChallengeStrategy.LeadingZeroes:
// request = {
// strategy: ChallengeStrategy.LeadingZeroes,
// salt: this.challengeData.salt,
// difficulty: this.challengeData.difficulty,
// };
// break;
// case ChallengeStrategy.TargetNumber:
// request = {
// strategy: ChallengeStrategy.TargetNumber,
// target: this.challengeData.target,
// salt: this.challengeData.salt,
// };
// break;
// }
switch (this.challengeData.strategy) {
case ChallengeStrategy.kCTF:
request = {
strategy: ChallengeStrategy.kCTF,
challenge: this.challengeData.challenge,
};
break;
}
console.log('Sending challenge to workers...');
// TODO: the first response is not always the solution, due to cmpxchg
// blocking, some workers may block on the read, and as soon as they
// unblock, they return 0 since the challenge is already solved.
//
// TODO: We need to do a better job of tracking solvers, so if one worker
// We need to do a better job of tracking solvers, so if one worker
// errors out, we only error out if all workers have errored out.
let worker_promises: Promise<SolutionMessage>[] = [];
for (let worker of this.solverWorkers) {
// dispatch to all workers, func is async so it will not block
worker_promises.push(this.issueChallengeToWorker(worker, request));
let worker_promises: Promise<string>[] = [];
for (let solver of this.solverWorkers) {
worker_promises.push(solver.solve_challenge(this.challengeData as Challenge));
}
let solution = await Promise.race(worker_promises);
if (solution.type === WorkerResponseType.Error) {
console.error("Worker error:", solution.error);
this.status = 'error';
return;
}
if (solution.type !== WorkerResponseType.Solution) {
console.error("Worker sent spurious message");
this.status = 'error';
return;
}
// TODO: configure if we should fetch or not
try {
await fetch(`${this.challengeUrl}/challenge`, {
method: 'POST',
body: JSON.stringify({
challenge: this.challengeData.challenge,
solution: solution.solution,
}),
headers: {
'Content-Type': 'application/json'
}
})
await Promise.race(worker_promises);
// The true solution is stored in the SharedArrayBuffer.
this.solution = String(Atomics.load(atomics_view, 1));
this.status = 'solved';
} catch (error: any) {
console.error("Captcha solving failed:", error);
this.status = 'error';
} finally {
if (this.hashRateInterval !== null) {
clearInterval(this.hashRateInterval);
this.hashRateInterval = null;
}
}
if (this.status === 'solved') {
this._internals!.setFormValue(JSON.stringify({
challenge: this.challengeData.salt,
solution: this.solution,
}));
this.dispatchEvent(new CustomEvent('impost:solved', {
detail: {
challenge: this.challengeData.challenge,
solution: solution.solution,
challenge: this.challengeData,
solution: this.solution,
},
bubbles: true,
composed: true,
}))
} catch {
console.error('Failed to submit solution');
this.status = 'error';
}));
}
}
@@ -435,6 +389,12 @@ export class PowCaptcha extends LitElement {
</div>
<div class="impost-footer">
${this.showHashrate && this.status === 'solving' && this.hashRate > 0 ? html`
<div>
<span>H/s:</span>
<span>${this.hashRate.toFixed(2)}</span>
</div>
` : ``}
<div id="provider-link">
Protected by <a href="https://github.com/impost/pow-captcha" target="_blank">Impost</a>
</div>
@@ -443,8 +403,9 @@ export class PowCaptcha extends LitElement {
<input
type = "text"
id = "impost-solution"
class="hidden"
name = "impost-solution"
id = "impost-solution-${this.uid}"
style="display: none;"
.value = ${this.solution}
/>
`;

View File

@@ -1 +1 @@
export * from './pow-captcha';
export * from './captcha';

View File

@@ -1,77 +1,64 @@
// This worker just sits on another thread and waits for message to solve
// challenges so that we dont block the render thread
import {
type WorkerRequest,
type SolutionMessage,
WorkerMessageType,
WorkerResponseType,
} from "./types/worker";
import { type SolverModule, init_solver, solve } from '@impost/lib/solver';
import { type Challenge } from "@impost/lib";
import { type SolverModule, init_solver, solve_challenge as libimpost_solve_challenge } from '@impost/lib/solver';
import * as Comlink from 'comlink';
let solver: SolverModule | null = null;
let atomic_nonce: Int32Array | null = null;
let atomic_solution: Int32Array | null = null;
onmessage = async (event: MessageEvent<WorkerRequest>) => {
if (event.data.type === WorkerMessageType.Init) {
atomic_nonce = new Int32Array(event.data.sab, 0, 1);
atomic_solution = new Int32Array(event.data.sab, 4, 1);
let module = event.data.module;
/**
* Initializes the worker.
* Must be called before any solve functions.
* @param module The solver web assembly module.
* @param sab The SharedArrayBuffer for nonce and solution.
*/
export async function init(module: WebAssembly.Module, sab: SharedArrayBuffer): Promise<void> {
atomic_nonce = new Int32Array(sab, 0, 1);
atomic_solution = new Int32Array(sab, 4, 1);
try {
solver = await init_solver({
__get_solution: () => Atomics.load(atomic_solution!, 0),
__set_solution: (value: number) => Atomics.store(atomic_solution!, 0, value),
__cmpxchg_solution: (expected: number, replacement: number) => Atomics.compareExchange(atomic_solution!, 0, expected, replacement),
__fetch_add_nonce: (value: number) => Atomics.add(atomic_nonce!, 0, value),
__log: (str_ptr: number, str_len: number) => console.log(new TextDecoder().decode(new Uint8Array(solver!.exports.memory.buffer, str_ptr, str_len))),
}, module);
} catch (error: any) {
postMessage({
type: WorkerResponseType.Error,
error: `Could not load WASM solver: ${error.message}`,
} as SolutionMessage);
return;
}
if (!solver) {
postMessage({
type: WorkerResponseType.Error,
error: "Failed to load WASM solver",
} as SolutionMessage);
return;
}
postMessage({
type: WorkerResponseType.InitOk,
} as SolutionMessage);
return;
try {
solver = await init_solver({
__get_solution: () => Atomics.load(atomic_solution!, 0),
__set_solution: (value: number) => Atomics.store(atomic_solution!, 0, value),
__cmpxchg_solution: (expected: number, replacement: number) => Atomics.compareExchange(atomic_solution!, 0, expected, replacement),
__fetch_add_nonce: (value: number) => Atomics.add(atomic_nonce!, 0, value),
__log: (str_ptr: number, str_len: number) => console.log(new TextDecoder().decode(new Uint8Array(solver!.exports.memory.buffer, str_ptr, str_len))),
}, module);
} catch (error: any) {
console.error("Worker: Failed to initialize WASM solver", error);
throw new Error(`Could not load WASM solver in worker: ${error.message}`);
}
if (!solver) {
postMessage({
type: WorkerResponseType.Error,
error: "WASM solver not loaded",
} as SolutionMessage);
return;
throw new Error("Worker: Failed to load WASM solver.");
}
}
/**
* Solves a given challenge.
* @param challenge The challenge data.
* @returns The nonce solution on success, or throws an error.
*/
export async function solve_challenge(challenge: Challenge): Promise<string> {
if (!solver || !atomic_nonce || !atomic_solution) {
throw new Error("WASM solver or atomics not initialized in worker. Call init() first.");
}
let solution: string;
try {
solution = solve(solver, event.data.challenge);
} catch (error: any) {
postMessage({
type: WorkerResponseType.Error,
error: `Failed to solve challenge: ${error.message}`,
} as SolutionMessage);
return;
console.log("Worker: Solving challenge...");
const solutionNonce = libimpost_solve_challenge(solver, challenge);
if (solutionNonce < 0) {
throw new Error(`Worker: Failed to solve challenge. Internal code: ${solutionNonce}`);
}
postMessage({
type: WorkerResponseType.Solution,
solution,
} as SolutionMessage);
};
const finalSolution = Atomics.load(atomic_solution, 0);
return finalSolution.toString();
}
// * Do not forget to expose functions we want to use on the main thread
Comlink.expose({ solve_challenge, init });

View File

@@ -1,69 +0,0 @@
import { ChallengeStrategy } from "@impost/lib";
export enum WorkerMessageType {
Init = "init",
Challenge = "challenge",
}
interface WorkerInitRequest {
type: WorkerMessageType.Init;
module: WebAssembly.Module;
sab: SharedArrayBuffer;
}
// interface ChallengeLeadingZeroesSolveRequest {
// strategy: ChallengeStrategy.LeadingZeroes;
// salt: string;
// difficulty: number;
// }
// interface WorkerChallengeLeadingZeroesSolveRequest extends ChallengeLeadingZeroesSolveRequest {
// type: WorkerMessageType.Challenge;
// }
// interface ChallengeTargetNumberSolveRequest {
// strategy: ChallengeStrategy.TargetNumber;
// target: string;
// salt: string;
// }
// interface WorkerChallengeTargetNumberSolveRequest extends ChallengeTargetNumberSolveRequest {
// type: WorkerMessageType.Challenge;
// }
interface ChallengekCTFSolveRequest {
strategy: ChallengeStrategy.kCTF;
challenge: string;
}
interface WorkerChallengekCTFSolveRequest extends ChallengekCTFSolveRequest {
type: WorkerMessageType.Challenge;
}
export type ChallengeSolveRequest = ChallengekCTFSolveRequest;
type WorkerChallengeSolveRequest = WorkerChallengekCTFSolveRequest;
export type WorkerRequest = WorkerInitRequest | WorkerChallengeSolveRequest;
export enum WorkerResponseType {
Error = "error",
InitOk = "init_ok",
Solution = "solution",
}
interface ErrorMessageResponse {
type: WorkerResponseType.Error;
error: string;
}
interface SolutionMessageResponse {
type: WorkerResponseType.Solution;
solution: string;
}
interface InitOkMessageResponse {
type: WorkerResponseType.InitOk;
}
export type SolutionMessage = ErrorMessageResponse | SolutionMessageResponse | InitOkMessageResponse;

View File

@@ -1 +1 @@
/// <reference types="vite/client" />
/// <reference types="vite/client" />

View File

@@ -17,13 +17,10 @@ export default defineConfig({
dir: 'dist',
},
},
// WARN: this setting has caused issues for me in the past, but now it
// seems to work fine. I'm not sure why, but minifying the bundle saves
// 5KB, and it doesnt *cause* issues, so I'm leaving it on.
minify: true,
minify: true
},
plugins: [
dts(),
dts()
],
});

8648
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

10
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,10 @@
packages:
- packages/*
- example-app/
onlyBuiltDependencies:
- '@parcel/watcher'
- core-js-pure
- esbuild
shamefully-hoist: true

View File

@@ -1,215 +0,0 @@
// A PoW algorithm based on google's kCTF scheme
// https://google.github.io/kctf/
const std = @import("std");
const Allocator = std.mem.Allocator;
const assert = std.debug.assert;
const math = std.math;
const Int = math.big.int.Managed;
var managed_one: ?Int = null;
fn get_bit(n: *Int, idx: usize) !bool {
if (n.len() < idx / @typeInfo(usize).int.bits) {
return false;
}
var foo = try n.clone();
defer foo.deinit();
try foo.shiftRight(n, idx);
try foo.bitAnd(&foo, &managed_one.?);
return foo.eql(managed_one.?);
}
pub fn square_mod(n: *Int) !void {
const allocator = n.allocator;
try n.sqr(n);
var high = try Int.init(allocator);
defer high.deinit();
try high.shiftRight(n, 1279); // high = n >> 1279
var mask = try Int.init(allocator);
defer mask.deinit();
if (managed_one == null) {
managed_one = try Int.init(allocator);
try managed_one.?.set(1);
}
try mask.set(1);
try mask.shiftLeft(&mask, 1279);
try mask.sub(&mask, &managed_one.?);
try n.bitAnd(n, &mask);
try n.add(n, &high);
if (try get_bit(n, 1279)) {
// clear bit 1279
var power_of_2 = try Int.init(allocator);
defer power_of_2.deinit();
try power_of_2.set(1);
try power_of_2.shiftLeft(&power_of_2, 1279);
try n.sub(n, &power_of_2);
// *n += 1;
try n.add(n, &managed_one.?);
}
}
pub const Version = "s";
pub const Challenge = struct {
difficulty: ?u32,
salt: std.math.big.int.Managed,
const Self = @This();
pub fn destroy(self: *Self, allocator: Allocator) void {
self.salt.deinit();
allocator.destroy(self);
}
pub fn encode(self: *Self, allocator: Allocator) ![]u8 {
const solution_base64_len = std.base64.standard.Encoder.calcSize(self.salt.len() * @sizeOf(usize));
const dest = try allocator.alloc(u8, solution_base64_len);
defer allocator.free(dest);
@memset(dest, 0);
const limbs_u8_buffer: []u8 = std.mem.sliceAsBytes(self.salt.limbs[0..self.salt.len()]);
const base64_str = std.base64.standard.Encoder.encode(dest, limbs_u8_buffer);
return try std.fmt.allocPrint(allocator, "{s}", .{base64_str});
}
};
pub fn decode(allocator: Allocator, challenge: []const u8) !*Challenge {
var parts = std.mem.splitAny(u8, challenge, ".");
if (parts.next()) |part| {
if (!std.mem.eql(u8, part, Version)) {
return error.InvalidChallenge;
}
} else {
return error.InvalidChallenge;
}
var difficulty: ?u32 = null;
var next_part = parts.next() orelse return error.InvalidChallenge;
if (parts.peek()) |_| {
// must be <version>.<difficulty>.<salt>
const difficulty_bytes = try allocator.alloc(u8, try std.base64.standard.Decoder.calcSizeForSlice(next_part));
defer allocator.free(difficulty_bytes);
try std.base64.standard.Decoder.decode(difficulty_bytes, next_part);
std.log.info("Decoded difficulty bytes: {any}\n", .{difficulty_bytes});
var difficulty_array: [4]u8 = .{0} ** 4;
if (difficulty_bytes.len > 4) {
const split_idx = difficulty_bytes.len - 4;
for (difficulty_bytes[0..split_idx]) |byte| {
if (byte != 0) return error.DifficultyTooLarge;
}
@memcpy(&difficulty_array, difficulty_bytes[split_idx..]);
difficulty = std.mem.readInt(u32, &difficulty_array, .big);
} else {
const start_idx = 4 - difficulty_bytes.len;
@memcpy(&difficulty_array, difficulty_bytes[start_idx..]);
difficulty = std.mem.readInt(u32, &difficulty_array, .big);
}
next_part = parts.next() orelse return error.InvalidChallenge;
}
var salt = try std.math.big.int.Managed.init(allocator);
errdefer salt.deinit();
const salt_str = next_part;
const salt_bytes_len = try std.base64.standard.Decoder.calcSizeForSlice(salt_str);
std.log.info("salt_bytes_len: {d}\n", .{salt_bytes_len});
const salt_bytes = try allocator.alloc(u8, salt_bytes_len);
defer allocator.free(salt_bytes);
try std.base64.standard.Decoder.decode(salt_bytes, salt_str);
std.log.info("decoded salt: {any}\n", .{salt_bytes});
const usize_salt_bytes: []align(1) usize = std.mem.bytesAsSlice(usize, salt_bytes);
// TODO: the bytes are being read in as little endian, but need to be read in as big endian
std.log.info("usize_salt_bytes: {any}\n", .{usize_salt_bytes});
try salt.ensureCapacity(usize_salt_bytes.len);
@memcpy(salt.limbs[0..usize_salt_bytes.len], usize_salt_bytes);
salt.setLen(usize_salt_bytes.len);
const challenge_ptr = try allocator.create(Challenge);
errdefer challenge_ptr.destroy(allocator);
challenge_ptr.* = Challenge{
.difficulty = difficulty,
.salt = salt,
};
return challenge_ptr;
}
pub fn solve(allocator: Allocator, challenge: *Challenge) ![]u8 {
if (challenge.difficulty == null) {
return error.InvalidChallenge;
}
for (0..challenge.difficulty.?) |_| {
std.log.info("Solving challenge with difficulty {d}\n", .{challenge.difficulty.?});
for (0..1277) |_| {
try square_mod(&challenge.salt);
}
try challenge.salt.bitXor(&challenge.salt, &managed_one.?);
}
std.log.info("solved challenge: {any}\n", .{challenge});
return try challenge.encode(allocator);
}
pub fn check(allocator: Allocator, challenge: *Challenge, solution: *Challenge) !bool {
std.log.info("{d}", .{challenge.difficulty.?});
std.log.info("{any} vs {any}\n", .{ challenge, solution });
if (challenge.difficulty == null) {
return error.InvalidChallenge;
}
if (managed_one == null) {
managed_one = try Int.init(allocator);
try managed_one.?.set(1);
}
for (0..challenge.difficulty.?) |_| {
try solution.salt.bitXor(&solution.salt, &managed_one.?);
try square_mod(&solution.salt);
}
std.log.info("{any} vs {any}\n", .{ challenge, solution });
if (challenge.salt.eql(solution.salt)) {
return true;
}
var foo = try std.math.big.int.Managed.initSet(allocator, 2);
defer foo.deinit();
try foo.pow(&foo, 1279);
try foo.sub(&foo, &managed_one.?);
try foo.sub(&foo, &challenge.salt);
if (foo.eql(solution.salt)) {
std.log.info("challenge solved!\n", .{});
return true;
}
return false;
}

View File

@@ -1,88 +0,0 @@
const std = @import("std");
const builtin = @import("builtin");
const Allocator = std.mem.Allocator;
const kCTF = @import("kctf.zig");
var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init;
var allocator = gpa.allocator();
extern fn __log(str_ptr: usize, str_len: usize) void;
fn log(comptime level: std.log.Level, comptime scope: @TypeOf(.EnumLiteral), comptime fmt: []const u8, args: anytype) void {
if (comptime builtin.target.cpu.arch != .wasm32) {
std.log.defaultLog(level, scope, fmt, args);
return;
}
const formatted = std.fmt.allocPrint(allocator, fmt, args) catch return;
__log(@intFromPtr(formatted.ptr), formatted.len);
allocator.free(formatted);
}
pub const std_options: std.Options = .{ .logFn = log };
export fn malloc(byte_count: usize) ?*u8 {
const ptr = allocator.alloc(u8, byte_count) catch return null;
return @ptrCast(ptr.ptr);
}
export fn free(ptr: ?*anyopaque, byte_count: usize) void {
if (ptr) |p| {
const cast_ptr: [*]u8 = @ptrCast(p);
allocator.free(cast_ptr[0..byte_count]);
}
}
// value_ptr is a string to the entire encoded challenge string (e.g. "s.xxxxxxxxx.xxxxxxx")
export fn solve(value_ptr: [*]u8, value_len: usize) usize {
const challenge_slice = value_ptr[0..value_len];
std.log.info("Solve called with challenge {s}\n", .{challenge_slice});
const challenge = kCTF.decode(allocator, challenge_slice) catch |err| {
std.log.info("Error decoding challenge: {s}\n", .{@errorName(err)});
return 0;
};
defer challenge.destroy(allocator);
std.log.info("decoded challenge {any}\n", .{challenge});
const solution = kCTF.solve(allocator, challenge) catch |err| {
std.log.info("Error solving challenge: {s}\n", .{@errorName(err)});
return 0;
};
std.log.info("Solution: {s}\n", .{solution});
const output_ptr = allocator.alloc(u8, solution.len + 4) catch return 0;
var output_slice = output_ptr[0 .. solution.len + 4];
if (output_slice.len - 2 > std.math.maxInt(u16)) {
return 0;
}
const output_len: u16 = @intCast(output_slice.len - 2);
// convert to little endian
output_slice[0] = @intCast(output_len & 0xFF); // LSB
output_slice[1] = @intCast(output_len >> 8); // MSB
@memcpy(output_slice[2..4], "s.");
@memcpy(output_slice[4 .. 4 + solution.len], solution);
allocator.free(solution);
return @intFromPtr(output_ptr.ptr);
}
pub fn main() anyerror!void {
if (comptime builtin.cpu.arch == .wasm32) return;
var args = try std.process.argsAlloc(allocator);
if (args.len < 2) {
std.log.err("Usage: zig run src/kctf.zig <challenge>", .{});
return;
}
const challenge = try kCTF.decode(allocator, args[1]);
const solution = try kCTF.solve(allocator, challenge);
std.log.info("Solution: {s}", .{solution});
}

View File

@@ -1,93 +0,0 @@
const std = @import("std");
const builtin = @import("builtin");
const Allocator = std.mem.Allocator;
const kCTF = @import("kctf.zig");
var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init;
var allocator = gpa.allocator();
extern fn __log(str_ptr: usize, str_len: usize) void;
fn log(comptime level: std.log.Level, comptime scope: @TypeOf(.EnumLiteral), comptime fmt: []const u8, args: anytype) void {
if (comptime builtin.target.cpu.arch != .wasm32) {
std.log.defaultLog(level, scope, fmt, args);
return;
}
const formatted = std.fmt.allocPrint(allocator, fmt, args) catch return;
__log(@intFromPtr(formatted.ptr), formatted.len);
allocator.free(formatted);
}
pub const std_options: std.Options = .{ .logFn = log };
export fn malloc(byte_count: usize) ?*u8 {
const ptr = allocator.alloc(u8, byte_count) catch return null;
return @ptrCast(ptr.ptr);
}
export fn free(ptr: ?*anyopaque, byte_count: usize) void {
if (ptr) |p| {
const cast_ptr: [*]u8 = @ptrCast(p);
allocator.free(cast_ptr[0..byte_count]);
}
}
fn bytesToHex(bytes: []const u8, buf: []u8) void {
const hex_chars = "0123456789abcdef";
var i: usize = 0;
while (i < bytes.len) : (i += 1) {
buf[i * 2] = hex_chars[(bytes[i] >> 4)];
buf[i * 2 + 1] = hex_chars[bytes[i] & 0x0F];
}
}
// challenge_ptr should look like s.<difficulty>.<challenge>
// solution_ptr should look like s.<solved_hash>
export fn validate(challenge_ptr: [*]u8, challenge_len: usize, solution_ptr: [*]u8, solution_len: usize) bool {
const challenge_buf = challenge_ptr[0..challenge_len];
const solution_buf = solution_ptr[0..solution_len];
std.log.info("Validate called with challenge {s} and solution {s}\n", .{ challenge_buf, solution_buf });
const challenge = kCTF.decode(allocator, challenge_buf) catch return false;
std.log.info("decoded challenge {any}\n", .{challenge});
const solution = kCTF.decode(allocator, solution_buf) catch return false;
defer {
challenge.destroy(allocator);
solution.destroy(allocator);
}
std.log.info("decoded challenge and solution\n", .{});
const is_valid = kCTF.check(allocator, challenge, solution) catch return false;
return is_valid;
}
pub fn main() anyerror!void {
if (comptime builtin.cpu.arch == .wasm32) return;
const args = try std.process.argsAlloc(allocator);
if (args.len < 3) {
std.log.err("Usage: zig run src/validator.zig <challenge> <solution>", .{});
return;
}
const challenge = try kCTF.decode(allocator, args[1]);
defer challenge.destroy(allocator);
const solution = try kCTF.decode(allocator, args[2]);
defer solution.destroy(allocator);
std.log.info("Challenge: {any}\n", .{challenge});
std.log.info("Solution: {any}\n", .{solution});
const is_valid = kCTF.check(allocator, challenge, solution) catch |err| {
std.log.info("Error checking challenge: {s}\n", .{@errorName(err)});
return;
};
std.log.info("Is valid: {}\n", .{is_valid});
}