import { LitElement, html, css, isServer, type PropertyValues } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { type Challenge } from '@impost/lib'; import { get_wasm_module } from '@impost/lib/solver'; import * as Comlink from 'comlink'; import SolverWorker from './solver-worker?worker&inline'; type SolverWorkerAPI = Comlink.Remote; @customElement('impost-captcha') export class ImpostCaptcha 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; } `; /// ================================================ /// Configuration /// ================================================ @property({ type: String }) auto: "onload" | "onfocus" | "onsubmit" | "off" = "off"; @property({ type: String }) challengeUrl: string = 'http://localhost:3000/api/pow'; @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 private uid: string = Math.floor(Math.random() * 100000).toString(); private _internals: ElementInternals | null = null; static formAssociated = true; @state() private solution: string | null = null; @state() private challengeData: Challenge | null = null; @state() private status: 'unsolved' | 'solving' | 'solved' | 'error' = 'unsolved'; @state() private disabled: boolean = true; @state() private hashRate: number = 0; // stores the nonce and solution atomics private sab: SharedArrayBuffer = new SharedArrayBuffer(8); 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) { case 'onload': this.solveChallenge(); break; case 'onfocus': this.addEventListener('focus', () => this.solveChallenge()); break; case 'onsubmit': if (this.parentElement?.nodeName === 'FORM') { 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; } } 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.nativeWorkers || []) { worker.terminate(); this.solverWorkers = null; this.nativeWorkers = null; } } getCurrentWorkingNonce() { return Atomics.load(new Uint32Array(this.sab), 0); } async fetchChallenge() { if (this.challengeData !== null) { return; } if (this.challengejson !== '' && this.challengeData === null) { this.challengeData = JSON.parse(this.challengejson); this.challengejson = ''; return; } // challenge data must be provided by the user when using SSR 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.status = 'error'; }); } async initWorkers() { this.solverWorkers = []; this.nativeWorkers = []; const num_workers = navigator.hardwareConcurrency; for (let i = 0; i < num_workers; i++) { const nativeWorker = new SolverWorker(); const comlinkWorker = Comlink.wrap(nativeWorker); this.nativeWorkers.push(nativeWorker); this.solverWorkers.push(comlinkWorker); } 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[] = []; for (let i = 0; i < this.solverWorkers.length; i++) { 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(() => { reject(new Error(`Function timeout after ${timeoutMs}ms`)); }, timeoutMs); }); await Promise.race([ 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); }).catch(error => { clearTimeout(timeout); console.error("Worker initialization failed:", error); this.status = 'error'; throw error; }); } async solveChallenge() { if (!this.challengeData || this.solverWorkers === null) { // in all normal cases, this should be impossible this.status = 'error'; return; } 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, })); this.status = 'solving'; this.solution = null; const atomics_view = new Int32Array(this.sab); // reset atomics Atomics.store(atomics_view, 0, 0); Atomics.store(atomics_view, 1, -1); 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[] = []; for (let solver of this.solverWorkers) { worker_promises.push(solver.solve_challenge(this.challengeData as Challenge)); } try { 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, 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.challengeData === null) { return html`
Loading captcha challenge...
`; } return html`
${this.status !== 'solving' ? html`${this.status === 'error' ? html`` : html` `}` : html` `}
`; } }