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, ChallengeAlgorithm } 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-error-icon { color: var(--impost-widget-error-icon-color, #FF8117); } .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 challengeData: Challenge | null = null; @state() private status: 'unsolved' | 'solving' | 'solved' | 'error' = 'unsolved'; @state() private disabled: boolean = true; // stores the nonce and solution atomics private sab: SharedArrayBuffer = new SharedArrayBuffer(8); private solverWorkers: Worker[] | 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(); for (const worker of this.solverWorkers || []) { worker.terminate(); this.solverWorkers = 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; } 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); console.error('Failed to fetch challenge'); }); } async initWorkers() { this.solverWorkers = []; const num_workers = navigator.hardwareConcurrency || 4; 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[] = []; for (let i = 0; i < this.solverWorkers.length; i++) { console.log('Worker', i); const worker = this.solverWorkers[i]!; worker_promises.push(new Promise((resolve, reject) => { const message_handler = (event: MessageEvent) => { 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(() => { console.error('Failed to initialize workers in time'); this.status = 'error'; 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 { return new Promise((resolve, reject) => { const message_handler = (event: MessageEvent) => { 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.algorithm) { case ChallengeAlgorithm.SHA256: switch (request.strategy) { case ChallengeStrategy.LeadingZeroes: worker.postMessage({ algorithm: ChallengeAlgorithm.SHA256, strategy: ChallengeStrategy.LeadingZeroes, salt: request.salt, difficulty: request.difficulty, } as WorkerRequest); break; case ChallengeStrategy.TargetNumber: worker.postMessage({ algorithm: ChallengeAlgorithm.SHA256, strategy: ChallengeStrategy.TargetNumber, target: request.target, salt: request.salt, } as WorkerRequest); break; } break; case ChallengeAlgorithm.Argon2id: switch (request.strategy) { case ChallengeStrategy.LeadingZeroes: worker.postMessage({ algorithm: ChallengeAlgorithm.Argon2id, strategy: ChallengeStrategy.LeadingZeroes, salt: request.salt, difficulty: request.difficulty, } as WorkerRequest); break; case ChallengeStrategy.TargetNumber: worker.postMessage({ algorithm: ChallengeAlgorithm.Argon2id, strategy: ChallengeStrategy.TargetNumber, target: request.target, salt: request.salt, } as WorkerRequest); break; } break; case ChallengeAlgorithm.kCTF: worker.postMessage({ algorithm: ChallengeAlgorithm.kCTF, salt: request.salt, difficulty: request.difficulty, } as WorkerRequest); break; } }); } async solveChallenge() { if (!this.challengeData || this.solverWorkers === null) { console.error('solveChallenge called before challenge is ready'); this.status = 'error'; return; } if (this.solution !== '') { return; } this.dispatchEvent(new CustomEvent('impost:solve', { bubbles: true, composed: true, })) console.log(this.challengeData); this.status = 'solving'; 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; // } switch (this.challengeData.algorithm) { case ChallengeAlgorithm.SHA256: switch (this.challengeData.strategy) { case ChallengeStrategy.LeadingZeroes: request = { algorithm: ChallengeAlgorithm.SHA256, strategy: ChallengeStrategy.LeadingZeroes, salt: this.challengeData.salt, difficulty: this.challengeData.difficulty, }; break; case ChallengeStrategy.TargetNumber: request = { algorithm: ChallengeAlgorithm.SHA256, strategy: ChallengeStrategy.TargetNumber, target: this.challengeData.target, salt: this.challengeData.salt, }; break; } break; case ChallengeAlgorithm.Argon2id: switch (this.challengeData.strategy) { case ChallengeStrategy.LeadingZeroes: request = { algorithm: ChallengeAlgorithm.Argon2id, strategy: ChallengeStrategy.LeadingZeroes, salt: this.challengeData.salt, difficulty: this.challengeData.difficulty, }; break; case ChallengeStrategy.TargetNumber: request = { algorithm: ChallengeAlgorithm.Argon2id, strategy: ChallengeStrategy.TargetNumber, target: this.challengeData.target, salt: this.challengeData.salt, }; break; } break; case ChallengeAlgorithm.kCTF: request = { algorithm: ChallengeAlgorithm.kCTF, salt: this.challengeData.salt, difficulty: this.challengeData.difficulty, }; 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 // errors out, we only error out if all workers have errored out. let worker_promises: Promise[] = []; if (request.algorithm === ChallengeAlgorithm.kCTF) { worker_promises.push(this.issueChallengeToWorker(this.solverWorkers[0], request)); } else { 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) { 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({ salt: this.challengeData.salt, solution: solution.solution, }), headers: { 'Content-Type': 'application/json' } }) this.status = 'solved'; this.dispatchEvent(new CustomEvent('impost:solved', { detail: { salt: this.challengeData.salt, solution: solution.solution, }, bubbles: true, composed: true, })) } catch { console.error('Failed to submit solution'); this.status = 'error'; } } 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` `} ${this.status === 'error' ? 'Something went wrong' : this.status === 'solved' ? 'Verified' : html`${this.status === 'solving' ? 'Verifying...' : 'I am not a robot'}`} `; } }