Initial commit

Once again a weird place to commit, I have already done a lot of work, but I am just bad at using git, okay.
This commit is contained in:
Zoe
2025-11-17 16:12:26 +00:00
commit cfab3d0b8f
58 changed files with 18689 additions and 0 deletions

2
packages/lib/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
dist
node_modules

60
packages/lib/README.md Normal file
View File

@@ -0,0 +1,60 @@
# @impost/lib
This package contains the types and WASM code for the impost. It is useful for
building other packages that want to use the WASM code, a client, or a server.
## Usage
```ts
import {
validate_challenge,
generate_target_number_challenge,
} from "@impost/lib/validator";
import {
init_solver,
get_wasm_module,
solve_target_number_challenge,
} from "@impost/lib/solver";
import { type Challenge, ChallengeStrategy } from "@impost/lib";
const challenge = await generate_target_number_challenge(
// timeout in ms
{
// impost automatically recognizes this parameter, its also recommended to
// add a timestamp to prevent replay or pre-computation attacks
expires_at: Date.now() + 3600,
}
// max number
1_000
);
if (challenge === null) {
throw new Error("Failed to generate challenge");
}
let 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),
},
await get_wasm_module()
);
const solution = await solve_target_number_challenge(solver, {
salt: challenge.salt,
target: challenge.target,
});
const is_valid = await validate_challenge(challenge, {
challenge: challenge.target,
nonce: solution,
});
if (is_valid) {
console.log("Challenge solved!", solution);
}
```

2498
packages/lib/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
packages/lib/package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "@impost/lib",
"version": "0.1.0",
"type": "module",
"license": "BSL-1.0",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./solver": {
"types": "./dist/solver.d.ts",
"import": "./dist/solver.js"
},
"./validator": {
"types": "./dist/validator.d.ts",
"import": "./dist/validator.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc && vite build",
"watch": "tsc && vite build --watch",
"dev": "vite",
"test": "echo \"Error: no test specified\" && exit 1"
},
"devDependencies": {
"oxc-minify": "^0.97.0",
"tslib": "^2.6.2",
"typescript": "^5.3.3",
"vite": "npm:rolldown-vite@latest",
"vite-plugin-dts": "^4.5.4"
},
"dependencies": {
"uuidv7": "^1.0.2"
}
}

28
packages/lib/src/index.ts Normal file
View File

@@ -0,0 +1,28 @@
export enum ChallengeAlgorithm {
Argon2id = "argon2id",
}
export enum ChallengeStrategy {
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 type Challenge = ChallengeLeadingZeroes | ChallengeTargetNumber;

View File

@@ -0,0 +1,92 @@
import WASMSolverUrl from '../../../solver/zig-out/bin/solver.wasm?url&inline';
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;
"memory": WebAssembly.Memory;
}
export interface SolverModule extends WebAssembly.Instance {
exports: WasmExports;
}
export type SolverEnv = {
__get_solution: () => number;
__set_solution: (value: number) => void;
__cmpxchg_solution: (expected: number, replacement: number) => number;
__fetch_add_nonce: (value: number) => number;
};
export async function get_wasm_module(): Promise<WebAssembly.Module> {
return WebAssembly.compileStreaming(fetch(WASMSolverUrl));;
}
export async function init_solver(env: SolverEnv, module: WebAssembly.Module): Promise<SolverModule> {
return await WebAssembly.instantiate(module, {
env,
}) as unknown as SolverModule;
}
export function solve_leaading_zeroes_challenge(solver: SolverModule, challenge: { salt: string, difficulty: number }): number {
const { salt, difficulty } = challenge;
const encoder = new TextEncoder();
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(salt_bytes, salt_ptr);
const ret = solver.exports.solve_leaading_zeroes_challenge(
salt_ptr,
salt_bytes.length,
difficulty,
);
if (ret < 0) {
throw new Error("Failed to solve challenge");
}
return ret;
}
export function solve_target_number_challenge(solver: SolverModule, challenge: { salt: string, target: string }): 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

@@ -0,0 +1,172 @@
import { ChallengeAlgorithm, ChallengeStrategy, type Challenge } 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_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;
}
export interface ValidatorModule extends WebAssembly.Instance {
exports: WasmExports;
}
function array_to_base64(buffer: ArrayBuffer): string {
return btoa(String.fromCharCode(...new Uint8Array(buffer)));
}
async function generate_leading_zeroes_challenge(parameters: Object, difficulty: number): Promise<Challenge> {
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 challenge: Challenge = {
algorithm: ChallengeAlgorithm.Argon2id,
strategy: ChallengeStrategy.LeadingZeroes,
salt,
difficulty,
};
return challenge;
}
async function generate_target_number_challenge(parameters: Object, max_number: number): Promise<Challenge | 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 salt_bytes = encoder.encode(salt);
const random_number_bytes = encoder.encode(random_number.toString());
const salt_ptr = validator.exports.malloc(salt_bytes.length);
const random_number_ptr = validator.exports.malloc(random_number_bytes.length);
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 null;
}
const memory = new Uint8Array(validator.exports.memory.buffer);
memory.set(salt_bytes, salt_ptr);
memory.set(random_number_bytes, random_number_ptr);
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));
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: Challenge = {
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;
}

1
packages/lib/src/vite-env.d.ts vendored Normal file
View File

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

View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"declaration": true,
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,29 @@
import { resolve } from 'path';
import { defineConfig } from 'vite';
import dts from 'vite-plugin-dts';
export default defineConfig({
build: {
target: 'es2022',
rollupOptions: {
input: [
resolve(__dirname, 'src/index.ts'),
resolve(__dirname, 'src/solver.ts'),
resolve(__dirname, 'src/validator.ts'),
],
preserveEntrySignatures: "strict",
output: {
dir: 'dist',
entryFileNames: '[name].js',
preserveModules: false
}
},
sourcemap: true
},
resolve: {
alias: {
'@': '/src'
}
},
plugins: [dts()]
});

2
packages/widget/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
dist
node_modules

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>vite-project</title>
<script type="module" src="/src/pow-captcha.ts"></script>
</head>
<body>
<pow-captcha />
</body>
</html>

2576
packages/widget/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
{
"name": "@impost/widget",
"version": "0.1.0",
"type": "module",
"license": "BSL-1.0",
"module": "./dist/impost.js",
"exports": {
".": {
"import": "./dist/impost.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc && vite build",
"watch": "tsc && vite build --watch",
"dev": "vite",
"test": "echo \"Error: no test specified\" && exit 1"
},
"peerDependencies": {
"lit": "^3.1.2",
"lit-element": "^3.1.2"
},
"devDependencies": {
"@types/node": "^20.11.24",
"lit": "^3.1.2",
"lit-element": "^3.1.2",
"oxc-minify": "^0.97.0",
"tslib": "^2.6.2",
"typescript": "^5.3.3",
"vite": "npm:rolldown-vite@latest",
"vite-plugin-dts": "^4.5.4"
}
}

View File

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

View File

@@ -0,0 +1,458 @@
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 { get_wasm_module } from '@impost/lib/solver';
import ChallengeWorker from './solver-worker?worker&inline';
@customElement('pow-captcha')
export class PowCaptcha extends LitElement {
static override styles = css`
:host {
display: block;
width: var(--impost-widget-width, 250px);
}
.impost-widget {
display: flex;
flex-direction: column;
border: 1px solid var(--impost-widget-border-color, #505050);
font-family: sans-serif;
background-color: var(--impost-widget-background-color, #070408);
border-radius: var(--impost-widget-border-radius, 8px);
}
.impost-main {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.75rem;
}
.impost-main span {
height: min-content;
}
.impost-checkbox {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
color: var(--impost-widget-checkbox-color, #6e6e6eff);
}
.impost-checkbox input {
height: 80%;
width: 80%;
margin: 0;
}
.impost-footer {
display: flex;
padding: 0.25rem 0.75rem;
font-size: 0.75rem;
color: var(--impost-widget-footer-color, #a2a2a2);
justify-content: space-around;
align-items: end;
}
.impost-footer div {
height: min-content;
}
.impost-footer #provider-link {
margin-left: auto;
}
.impost-footer div a {
color: var(--impost-widget-footer-color, #a2a2a2);
}
.impost-footer div a:hover {
text-decoration: none;
}
.hidden {
display: none;
}
`;
// one of: "load", "focus", "submit", "off"
@property({ type: String })
auto: "onload" | "onfocus" | "onsubmit" | "off" = "off";
@property({ type: String })
challengeUrl: string = 'http://localhost:3000/api/pow';
@property({ type: String })
challengejson: string = '';
// 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();
@state()
private solution: string = '';
@state()
private errorMessage: string = '';
@state()
private challengeData: Challenge | null = null;
@state()
private solved: boolean = false;
@state()
private isSolving: boolean = false;
@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 solveStartTime: number | null = null;
private hashRateInterval: number | null = null;
override connectedCallback() {
super.connectedCallback();
this.fetchChallenge();
this.initWorkers();
switch (this.auto) {
case 'onload':
this.solveChallenge();
break;
case 'onfocus':
this.addEventListener('focus', () => this.solveChallenge());
break;
case 'onsubmit':
if (this.parentElement?.nodeName === 'FORM') {
this.parentElement.addEventListener('submit', () => this.solveChallenge());
}
break;
}
}
protected override firstUpdated(_changedProperties: PropertyValues): void {
super.firstUpdated(_changedProperties);
this.disabled = false;
}
override disconnectedCallback() {
super.disconnectedCallback();
if (this.hashRateInterval !== null) {
clearInterval(this.hashRateInterval);
this.hashRateInterval = null;
}
for (const worker of this.solverWorkers || []) {
worker.terminate();
this.solverWorkers = null;
}
}
getCurrentWorkingNonce() {
return Atomics.load(new Uint32Array(this.sab), 0);
}
async fetchChallenge() {
this.errorMessage = '';
if (this.challengeData !== null) {
return;
}
if (this.challengejson !== '' && this.challengeData === null) {
this.challengeData = JSON.parse(this.challengejson);
this.challengejson = '';
return;
}
if (isServer) {
return;
}
await fetch(`${this.challengeUrl}/challenge`)
.then(response => response.json())
.then(data => {
this.challengeData = data;
})
.catch(error => {
console.error('Error fetching challenge:', error);
this.errorMessage = 'Failed to fetch challenge. Please try again.';
});
}
async initWorkers() {
this.solverWorkers = [];
const num_workers = navigator.hardwareConcurrency;
for (let i = 0; i < num_workers; i++) {
this.solverWorkers.push(new ChallengeWorker());
}
const atomics_view = new Int32Array(this.sab);
Atomics.store(atomics_view, 0, 0);
Atomics.store(atomics_view, 1, 0);
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 timeoutMs = 10 * 1000;
let timeout: number;
const timeoutPromise = new Promise((_, reject) => {
timeout = setTimeout(() => {
this.errorMessage = 'Failed to initialize workers in time. Please refresh the page.';
reject(new Error(`Function timed out after ${timeoutMs}ms`));
}, timeoutMs);
});
await Promise.race([
Promise.allSettled(worker_promises).then(() => {
console.log('All workers initialized');
}),
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;
}
});
}
async solveChallenge() {
if (!this.challengeData || this.solverWorkers === null) {
this.errorMessage = 'Captcha is not ready. Please wait or refresh.';
return;
}
if (this.solution !== '') {
return;
}
this.solveStartTime = performance.now();
this.hashRateInterval = setInterval(async () => {
const nonce = this.getCurrentWorkingNonce();
this.hashRate = (nonce / ((performance.now() - this.solveStartTime!) / 1000));
}, 250);
this.dispatchEvent(new CustomEvent('impost:solve', {
detail: {
solution: this.solution,
},
bubbles: true,
composed: true,
}))
console.log(this.challengeData);
this.isSolving = true;
this.errorMessage = '';
this.solution = '';
const atomics_view = new Int32Array(this.sab);
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;
}
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.
//
// 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 solution = await Promise.race(worker_promises);
if (solution.type === WorkerResponseType.Error) {
this.errorMessage = solution.error;
return;
}
if (solution.type !== WorkerResponseType.Solution) {
this.errorMessage = "Something went wrong, please try again later.";
return;
}
this.solution = Atomics.load(atomics_view, 1).toString();
this.isSolving = false;
this.solved = true;
if (this.hashRateInterval !== null) {
clearInterval(this.hashRateInterval);
this.hashRateInterval = null;
}
this.dispatchEvent(new CustomEvent('impost:solved', {
detail: {
solution: this.solution,
},
bubbles: true,
composed: true,
}))
}
solvePreventDefault(event: Event) {
event.preventDefault();
this.solveChallenge();
}
override render() {
if (this.challengejson !== '' && this.challengeData === null) {
this.challengeData = JSON.parse(this.challengejson);
this.challengejson = '';
}
if (this.errorMessage) {
return html`
<div class="error-message">${this.errorMessage}</div>
`;
}
if (this.challengeData === null) {
return html`
<div class="loading-message">Loading captcha challenge...</div>
`;
}
return html`
<div class="impost-widget">
<div class="impost-main">
<div class="impost-checkbox">
${!this.isSolving ? html`
<input type="checkbox" id="impost-checkbox-${this.uid}" @click=${this.solvePreventDefault} @change=${this.solvePreventDefault} ?disabled=${this.disabled} ?checked=${this.solved}>
` : html`
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24">
<!-- Icon from SVG Spinners by Utkarsh Verma - https://github.com/n3r4zzurr0/svg-spinners/blob/main/LICENSE -->
<g stroke="currentColor">
<circle cx="12" cy="12" r="9.5" fill="none" stroke-linecap="round" stroke-width="3">
<animate attributeName="stroke-dasharray" calcMode="spline" dur="1.5s" keySplines="0.42,0,0.58,1;0.42,0,0.58,1;0.42,0,0.58,1" keyTimes="0;0.475;0.95;1" repeatCount="indefinite" values="0 150;42 150;42 150;42 150"/>
<animate attributeName="stroke-dashoffset" calcMode="spline" dur="1.5s" keySplines="0.42,0,0.58,1;0.42,0,0.58,1;0.42,0,0.58,1" keyTimes="0;0.475;0.95;1" repeatCount="indefinite" values="0;-16;-59;-59"/>
</circle>
<animateTransform attributeName="transform" dur="2s" repeatCount="indefinite" type="rotate" values="0 12 12;360 12 12"/>
</g>
</svg>
`}
</div>
<label for="impost-checkbox-${this.uid}">${this.solved ? 'Verified' : html`${this.isSolving ? 'Verifying...' : 'I am not a robot'}`}</label>
</div>
<div class="impost-footer">
${this.isSolving && 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>
</div>
</div>
<input
type = "text"
id = "impost-solution"
class="hidden"
.value = ${this.solution}
/>
`;
}
}

View File

@@ -0,0 +1,91 @@
// 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 { ChallengeStrategy } from "@impost/lib";
import { type SolverModule, init_solver, solve_leaading_zeroes_challenge, solve_target_number_challenge } from '@impost/lib/solver';
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;
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),
}, 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;
}
if (!solver) {
postMessage({
type: WorkerResponseType.Error,
error: "WASM solver not loaded",
} as SolutionMessage);
return;
}
if (atomic_nonce === null || atomic_solution === null) {
throw new Error("Atomics not initialized");
}
const { strategy } = event.data;
let solution: number;
switch (strategy) {
case ChallengeStrategy.LeadingZeroes:
solution = solve_leaading_zeroes_challenge(solver, event.data)
break;
case ChallengeStrategy.TargetNumber:
solution = solve_target_number_challenge(solver, event.data)
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

@@ -0,0 +1,60 @@
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;
}
export type ChallengeSolveRequest = ChallengeLeadingZeroesSolveRequest | ChallengeTargetNumberSolveRequest;
type WorkerChallengeSolveRequest = WorkerChallengeLeadingZeroesSolveRequest | WorkerChallengeTargetNumberSolveRequest;
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;
nonce: string;
}
interface InitOkMessageResponse {
type: WorkerResponseType.InitOk;
}
export type SolutionMessage = ErrorMessageResponse | SolutionMessageResponse | InitOkMessageResponse;

1
packages/widget/src/vite-env.d.ts vendored Normal file
View File

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

View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"declaration": true,
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,29 @@
import { resolve } from 'path';
import { defineConfig } from 'vite';
import dts from 'vite-plugin-dts';
export default defineConfig({
build: {
target: 'es2022',
lib: {
entry: resolve(__dirname, 'src/entry.ts'),
formats: ['es'],
fileName: 'impost',
},
rollupOptions: {
external: [/^lit/, /^@lit\//],
output: {
preserveModules: false,
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,
},
plugins: [
dts(),
],
});