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

@@ -2058,7 +2058,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -2332,7 +2331,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -2383,7 +2381,6 @@
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",

View File

@@ -1,28 +1,44 @@
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
// 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
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 ChallengekCTF {
strategy: ChallengeStrategy.kCTF;
challenge: string;
}
export type Challenge = ChallengeLeadingZeroes | ChallengeTargetNumber;
export type InnerChallenge = InnerChallengekCTF;
export type Challenge = ChallengekCTF;

View File

@@ -3,8 +3,10 @@ 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;
// "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,
"memory": WebAssembly.Memory;
}
@@ -17,6 +19,7 @@ export type SolverEnv = {
__set_solution: (value: number) => void;
__cmpxchg_solution: (expected: number, replacement: number) => number;
__fetch_add_nonce: (value: number) => number;
__log: (str_ptr: number, str_len: number) => void;
};
export async function get_wasm_module(): Promise<WebAssembly.Module> {
@@ -29,64 +32,35 @@ export async function init_solver(env: SolverEnv, module: WebAssembly.Module): P
}) as unknown as SolverModule;
}
export function solve_leaading_zeroes_challenge(solver: SolverModule, challenge: { salt: string, difficulty: number }): number {
const { salt, difficulty } = challenge;
export function solve(solver: SolverModule, challenge: string): string {
console.log(challenge);
const encoder = new TextEncoder();
const challenge_buf = encoder.encode(challenge);
const salt_bytes = encoder.encode(salt);
const salt_ptr = solver.exports.malloc(salt_bytes.length);
if (salt_ptr === 0 || salt_ptr === null) {
const challenge_ptr = solver.exports.malloc(challenge_buf.length);
if (challenge_ptr === 0 || challenge_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);
memory.set(challenge_buf, challenge_ptr);
const ret = solver.exports.solve_leaading_zeroes_challenge(
salt_ptr,
salt_bytes.length,
difficulty,
);
const ret = solver.exports.solve(challenge_ptr, challenge_buf.length);
if (ret < 0) {
console.log("RET", ret);
if (ret <= 0) {
throw new Error("Failed to solve challenge");
}
return ret;
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;
}
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

@@ -1,12 +1,11 @@
import { ChallengeAlgorithm, ChallengeStrategy, type Challenge } from '.';
import { ChallengeStrategy, type Challenge, type InnerChallenge } from '.';
import WASMValidatorUrl from '../../../solver/zig-out/bin/validator.wasm?url&inline';
import { uuidv7obj } from 'uuidv7';
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;
"validate": (challenge_ptr: number, challenge_len: number, solution_ptr: number, solution_len: number) => boolean;
"memory": WebAssembly.Memory;
}
@@ -14,159 +13,92 @@ 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;
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]);
}
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;
return btoa(binary);
}
export interface LeadingZeroesChallengeConfig {
export interface kCTFChallengeConfig {
parameters: Object;
strategy: ChallengeStrategy.LeadingZeroes;
strategy: ChallengeStrategy.kCTF;
difficulty: number;
}
export interface TargetNumberChallengeConfig {
parameters: Object;
strategy: ChallengeStrategy.TargetNumber;
max_number: number;
}
export type ChallengeConfig = kCTFChallengeConfig;
export type ChallengeConfig = LeadingZeroesChallengeConfig | TargetNumberChallengeConfig;
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.
let parameters_str = Object.entries(parameters).map(([key, value]) => `${key}=${value}`).join("&");
if (parameters_str.length > 0) {
parameters_str = "?" + parameters_str;
}
return `${VERSION}.${difficulty_str}.${salt_str}${parameters_str}`;
}
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 (config.difficulty < 1) {
throw new Error("Difficulty must be at least 1");
}
if (challenge === null) {
return null;
const challenge: InnerChallenge = {
strategy: ChallengeStrategy.kCTF,
salt: uuidv7obj(),
difficulty: config.difficulty,
}
return challenge;
return {
strategy: ChallengeStrategy.kCTF,
challenge: await encode_challenge(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
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
const encoder = new TextEncoder();
const challenge_buf = encoder.encode(challenge.challenge);
const solution_buf = encoder.encode(challenge_solution);
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);
const challenge_ptr = validator.exports.malloc(challenge_buf.length);
const solution_ptr = validator.exports.malloc(solution_buf.length);
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;
if (challenge_ptr === 0 || challenge_ptr === null || solution_ptr === 0 || solution_ptr === null) {
console.error("Failed to allocate memory for challenge string");
return false;
}
return err === 0;
const memory = new Uint8Array(validator.exports.memory.buffer);
memory.set(challenge_buf, challenge_ptr);
memory.set(solution_buf, solution_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);
return is_valid;
}