Implement kCTF strategy

This implementation is pretty scuffed, but its more exploratory than anything else.
This commit is contained in:
Zoe
2025-11-21 16:20:07 +00:00
parent cfab3d0b8f
commit 570531fe32
22 changed files with 1090 additions and 1007 deletions

View File

@@ -21,6 +21,9 @@ 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;
@@ -86,32 +89,20 @@ export class PowCaptcha extends LitElement {
@state()
private solution: string = '';
@state()
private errorMessage: string = '';
@state()
private challengeData: Challenge | null = null;
@state()
private solved: boolean = false;
@state()
private isSolving: boolean = false;
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: Worker[] | null = null;
private solveStartTime: number | null = null;
private hashRateInterval: number | null = null;
override connectedCallback() {
super.connectedCallback();
this.fetchChallenge();
@@ -141,10 +132,6 @@ 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 || []) {
worker.terminate();
@@ -157,7 +144,6 @@ export class PowCaptcha extends LitElement {
}
async fetchChallenge() {
this.errorMessage = '';
if (this.challengeData !== null) {
return;
}
@@ -179,14 +165,14 @@ export class PowCaptcha extends LitElement {
})
.catch(error => {
console.error('Error fetching challenge:', error);
this.errorMessage = 'Failed to fetch challenge. Please try again.';
console.error('Failed to fetch challenge');
});
}
async initWorkers() {
this.solverWorkers = [];
const num_workers = navigator.hardwareConcurrency;
const num_workers = 1;
for (let i = 0; i < num_workers; i++) {
this.solverWorkers.push(new ChallengeWorker());
}
@@ -237,7 +223,8 @@ export class PowCaptcha extends LitElement {
let timeout: number;
const timeoutPromise = new Promise((_, reject) => {
timeout = setTimeout(() => {
this.errorMessage = 'Failed to initialize workers in time. Please refresh the page.';
console.error('Failed to initialize workers in time');
this.status = 'error';
reject(new Error(`Function timed out after ${timeoutMs}ms`));
}, timeoutMs);
});
@@ -272,19 +259,27 @@ export class PowCaptcha extends LitElement {
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.LeadingZeroes:
case ChallengeStrategy.kCTF:
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,
strategy: ChallengeStrategy.kCTF,
challenge: request.challenge,
} as WorkerRequest);
break;
}
@@ -293,7 +288,8 @@ export class PowCaptcha extends LitElement {
async solveChallenge() {
if (!this.challengeData || this.solverWorkers === null) {
this.errorMessage = 'Captcha is not ready. Please wait or refresh.';
console.error('solveChallenge called before challenge is ready');
this.status = 'error';
return;
}
@@ -301,25 +297,14 @@ export class PowCaptcha extends LitElement {
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.status = 'solving';
this.solution = '';
const atomics_view = new Int32Array(this.sab);
@@ -328,19 +313,27 @@ export class PowCaptcha extends LitElement {
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.LeadingZeroes:
case ChallengeStrategy.kCTF:
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,
strategy: ChallengeStrategy.kCTF,
challenge: this.challengeData.challenge,
};
break;
}
@@ -350,7 +343,7 @@ export class PowCaptcha extends LitElement {
// 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
// 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<SolutionMessage>[] = [];
for (let worker of this.solverWorkers) {
@@ -361,31 +354,44 @@ export class PowCaptcha extends LitElement {
let solution = await Promise.race(worker_promises);
if (solution.type === WorkerResponseType.Error) {
this.errorMessage = solution.error;
console.error("Worker error:", solution.error);
this.status = 'error';
return;
}
if (solution.type !== WorkerResponseType.Solution) {
this.errorMessage = "Something went wrong, please try again later.";
console.error("Worker sent spurious message");
this.status = 'error';
return;
}
this.solution = Atomics.load(atomics_view, 1).toString();
this.isSolving = false;
this.solved = true;
// 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'
}
})
if (this.hashRateInterval !== null) {
clearInterval(this.hashRateInterval);
this.hashRateInterval = null;
this.status = 'solved';
this.dispatchEvent(new CustomEvent('impost:solved', {
detail: {
challenge: this.challengeData.challenge,
solution: solution.solution,
},
bubbles: true,
composed: true,
}))
} catch {
console.error('Failed to submit solution');
this.status = 'error';
}
this.dispatchEvent(new CustomEvent('impost:solved', {
detail: {
solution: this.solution,
},
bubbles: true,
composed: true,
}))
}
solvePreventDefault(event: Event) {
@@ -400,12 +406,6 @@ export class PowCaptcha extends LitElement {
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>
@@ -416,9 +416,9 @@ export class PowCaptcha extends LitElement {
<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`
${this.status !== 'solving' ? html`${this.status === 'error' ? html`<svg class="impost-error-icon" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Material Symbols by Google - https://github.com/google/material-design-icons/blob/master/LICENSE --><path fill="currentColor" d="M12 14q-.425 0-.712-.288T11 13V6q0-.425.288-.712T12 5t.713.288T13 6v7q0 .425-.288.713T12 14m0 5q-.425 0-.712-.288T11 18t.288-.712T12 17t.713.288T13 18t-.288.713T12 19"/></svg>` : html`
<input type="checkbox" id="impost-checkbox-${this.uid}" @click=${this.solvePreventDefault} @change=${this.solvePreventDefault} ?disabled=${this.disabled} ?checked=${this.status === '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">
@@ -431,16 +431,10 @@ export class PowCaptcha extends LitElement {
</svg>
`}
</div>
<label for="impost-checkbox-${this.uid}">${this.solved ? 'Verified' : html`${this.isSolving ? 'Verifying...' : 'I am not a robot'}`}</label>
<label for="impost-checkbox-${this.uid}">${this.status === 'error' ? 'Something went wrong' : this.status === 'solved' ? 'Verified' : html`${this.status === 'solving' ? '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>