UNPKG

altcha

Version:

Privacy-first CAPTCHA widget, compliant with global regulations (GDPR/HIPAA/CCPA/LGDP/DPDPA/PIPL) and WCAG accessible. No tracking, self-verifying.

617 lines (616 loc) 16.9 kB
const noop = () => { }; function safe_not_equal(a, b) { return a != a ? b == b : a !== b || a !== null && typeof a === "object" || typeof a === "function"; } function subscribe_to_store(store2, run, invalidate) { if (store2 == null) { run(void 0); return noop; } const unsub = untrack( () => store2.subscribe( run, // @ts-expect-error invalidate ) ); return unsub.unsubscribe ? () => unsub.unsubscribe() : unsub; } const subscriber_queue = []; function writable(value, start = noop) { let stop = null; const subscribers = /* @__PURE__ */ new Set(); function set(new_value) { if (safe_not_equal(value, new_value)) { value = new_value; if (stop) { const run_queue = !subscriber_queue.length; for (const subscriber of subscribers) { subscriber[1](); subscriber_queue.push(subscriber, value); } if (run_queue) { for (let i = 0; i < subscriber_queue.length; i += 2) { subscriber_queue[i][0](subscriber_queue[i + 1]); } subscriber_queue.length = 0; } } } } function update(fn) { set(fn( /** @type {T} */ value )); } function subscribe(run, invalidate = noop) { const subscriber = [run, invalidate]; subscribers.add(subscriber); if (subscribers.size === 1) { stop = start(set, update) || noop; } run( /** @type {T} */ value ); return () => { subscribers.delete(subscriber); if (subscribers.size === 0 && stop) { stop(); stop = null; } }; } return { set, update, subscribe }; } function get(store2) { let value; subscribe_to_store(store2, (_) => value = _)(); return value; } let untracking = false; function untrack(fn) { var previous_untracking = untracking; try { untracking = true; return fn(); } finally { untracking = previous_untracking; } } function store(defaultValue) { const scope = { get: (name) => { return get(scope.store)[name]; }, set: (name, value) => { if (typeof name === "string") { Object.assign(get(scope.store), { [name]: value }); } else { Object.assign(get(scope.store), name); } scope.store.set(get(scope.store)); }, store: writable(defaultValue) }; return scope; } globalThis.$altcha = globalThis.$altcha || { algorithms: /* @__PURE__ */ new Map(), defaults: store({}), i18n: store({}), instances: /* @__PURE__ */ new Set(), plugins: /* @__PURE__ */ new Set() }; class BasePlugin { constructor(host) { this.host = host; } static register(pluginClass) { if ("$altcha" in globalThis && !globalThis.$altcha.plugins.has(pluginClass)) { globalThis.$altcha.plugins.add(pluginClass); } } async onFetchChallenge(source) { } async onRequestServerVerification(payload, code) { } async onVerify(value) { } } function getDigest(algorithm) { switch (algorithm) { case "PBKDF2/SHA-512": return "SHA-512"; case "PBKDF2/SHA-384": return "SHA-384"; case "PBKDF2/SHA-256": default: return "SHA-256"; } } async function deriveKey(parameters, salt, password) { const { algorithm, cost, keyLength = 32 } = parameters; const passwordKey = await crypto.subtle.importKey( "raw", password, { name: "PBKDF2" }, false, ["deriveKey"] ); const derivedKey = await crypto.subtle.deriveKey( { name: "PBKDF2", salt, iterations: cost, hash: getDigest(algorithm) }, passwordKey, { name: "AES-GCM", length: keyLength * 8 }, true, ["encrypt"] ); return { derivedKey: new Uint8Array(await crypto.subtle.exportKey("raw", derivedKey)) }; } function bufferStartsWith(buffer, prefix) { if (prefix.length > buffer.length) { return false; } for (let i = 0; i < prefix.length; i++) { if (buffer[i] !== prefix[i]) { return false; } } return true; } function bufferToHex(buffer) { return Array.from(new Uint8Array(buffer)).map((b) => b.toString(16).padStart(2, "0")).join(""); } function concatBuffers(a, b) { const out = new Uint8Array(a.length + b.length); out.set(a, 0); out.set(b, a.length); return out; } function hexToBuffer(hex) { if (hex.length % 2 !== 0) { throw new Error(`Hex string must have an even length. Got: ${hex}`); } const buffer = new ArrayBuffer(hex.length / 2); const view = new DataView(buffer); for (let i = 0; i < hex.length; i += 2) { const byteString = hex.substring(i, i + 2); const byteValue = parseInt(byteString, 16); view.setUint8(i / 2, byteValue); } return new Uint8Array(buffer); } async function delay(ms) { await new Promise((resolve) => setTimeout(resolve, ms)); } async function hmac(algorithm, data, keyStr) { const key = await crypto.subtle.importKey( "raw", new TextEncoder().encode(keyStr), { name: "HMAC", hash: { name: algorithm } }, false, ["sign", "verify"] ); const signature = await crypto.subtle.sign( "HMAC", key, typeof data === "string" ? new TextEncoder().encode(data) : data ); return new Uint8Array(signature); } function sortKeys(obj) { if (typeof obj !== "object" || obj === null || Array.isArray(obj)) { return obj; } return Object.keys(obj).sort().reduce((acc, key) => { const value = obj[key]; if (value !== void 0) { acc[key] = sortKeys(value); } return acc; }, {}); } function timeDuration(start) { return Math.floor((performance.now() - start) * 10) / 10; } var HmacAlgorithm = /* @__PURE__ */ ((HmacAlgorithm2) => { HmacAlgorithm2["SHA_256"] = "SHA-256"; HmacAlgorithm2["SHA_384"] = "SHA-384"; HmacAlgorithm2["SHA_512"] = "SHA-512"; return HmacAlgorithm2; })(HmacAlgorithm || {}); var State = /* @__PURE__ */ ((State2) => { State2["CODE"] = "code"; State2["ERROR"] = "error"; State2["VERIFIED"] = "verified"; State2["VERIFYING"] = "verifying"; State2["UNVERIFIED"] = "unverified"; State2["EXPIRED"] = "expired"; return State2; })(State || {}); class PasswordBuffer { constructor(nonce, mode = "uint32") { this.nonce = nonce; this.mode = mode; this.buffer = new Uint8Array(this.nonce.length + this.COUNTER_BYTES); this.buffer.set(this.nonce, 0); this.dataView = new DataView(this.buffer.buffer); } COUNTER_BYTES = 4; buffer; dataView; encoder = new TextEncoder(); /** * Appends the counter to the nonce buffer. * In 'string' mode, encodes the counter as a UTF-8 string. * In 'uint32' mode, writes the counter as a big-endian 32-bit integer. */ setCounter(n) { if (this.mode === "string") { return concatBuffers(this.nonce, this.encoder.encode(n.toString())); } this.dataView.setUint32(this.nonce.length, n, false); return this.buffer; } } async function createChallenge(options) { const { algorithm, counter, counterMode = "uint32", cost, deriveKey: deriveKey2, data, expiresAt, hmacAlgorithm = HmacAlgorithm.SHA_256, hmacKeySignatureSecret, hmacSignatureSecret, keyLength = 32, keyPrefix = "00", keyPrefixLength = keyLength / 2, memoryCost, parallelism } = options; const parameters = { algorithm, nonce: bufferToHex(crypto.getRandomValues(new Uint8Array(16))), salt: bufferToHex(crypto.getRandomValues(new Uint8Array(16))), cost, keyLength, memoryCost, parallelism, keyPrefix, expiresAt: expiresAt instanceof Date ? Math.floor(expiresAt.getTime() / 1e3) : expiresAt, data }; let deriveKeyResult = null; if (counter !== void 0) { const nonceBuf = hexToBuffer(parameters.nonce); deriveKeyResult = await deriveKey2( parameters, hexToBuffer(parameters.salt), new PasswordBuffer(nonceBuf, counterMode).setCounter(counter) ); if (deriveKeyResult.parameters) { Object.assign(parameters, deriveKeyResult.parameters); } parameters.keyPrefix = bufferToHex(deriveKeyResult.derivedKey.slice(0, keyPrefixLength)); } if (!hmacSignatureSecret) { return { parameters: sortKeys(parameters) }; } return signChallenge( hmacAlgorithm, parameters, deriveKeyResult?.derivedKey, hmacSignatureSecret, hmacKeySignatureSecret ); } async function solveChallenge(options) { const { challenge, controller, counterMode = "uint32", counterStart = 0, counterStep = 1, deriveKey: deriveKey2, timeout = 9e4 } = options; const { nonce, keyPrefix, salt } = challenge.parameters; const nonceBuf = hexToBuffer(nonce); const saltBuf = hexToBuffer(salt); const keyPrefixBuf = keyPrefix.length % 2 === 0 ? hexToBuffer(keyPrefix) : null; const password = new PasswordBuffer(nonceBuf, counterMode); const start = performance.now(); let counter = counterStart; let iterations = 0; let derivedKeyHex = ""; let lastYield = start; while (true) { if (controller?.signal.aborted || timeout && iterations % 10 === 0 && performance.now() - start > timeout) { return null; } const { derivedKey } = await deriveKey2( challenge.parameters, saltBuf, password.setCounter(counter) ); if (iterations % 10 === 0 && performance.now() - lastYield > 200) { await delay(0); lastYield = performance.now(); } if (keyPrefixBuf ? bufferStartsWith(derivedKey, keyPrefixBuf) : bufferToHex(derivedKey).startsWith(keyPrefix)) { derivedKeyHex = bufferToHex(derivedKey); break; } counter = counter + counterStep; iterations = iterations + 1; } return { counter, derivedKey: derivedKeyHex, time: timeDuration(start) }; } async function solveChallengeWorkers(options) { const { challenge, concurrency = navigator.hardwareConcurrency, controller = new AbortController(), createWorker, onOutOfMemory = (c) => c > 1 ? Math.floor(c / 2) : 0, counterMode, timeout = 9e4 } = options; const workersConcurrency = Math.min(16, Math.max(1, concurrency)); const workersInstances = []; const terminate = () => { for (const worker of workersInstances) { worker.terminate(); } }; for (let i = 0; i < workersConcurrency; i++) { workersInstances.push(await createWorker(challenge.parameters.algorithm)); } let solution = null; try { solution = await Promise.race( workersInstances.map((worker, i) => { controller.signal.addEventListener("abort", () => { worker.postMessage({ type: "abort" }); }); return new Promise((resolve, reject) => { worker.addEventListener("error", (err) => { reject(err); }); worker.addEventListener("message", (message) => { if (message.data) { for (const w of workersInstances) { if (w !== worker) { w.postMessage({ type: "abort" }); } } if (message.data.error) { return reject(new Error(message.data.error)); } } resolve(message.data); }); worker.postMessage({ challenge, counterMode, counterStart: i, counterStep: workersConcurrency, timeout, type: "work" }); }); }) ); } catch (err) { const isOOM = err instanceof Error && !!err?.message?.includes("Out of memory"); if (isOOM) { if (onOutOfMemory) { terminate(); const retryConcurrency = onOutOfMemory(workersConcurrency); if (retryConcurrency) { return solveChallengeWorkers({ ...options, challenge, controller, concurrency: retryConcurrency, createWorker }); } } } throw err; } finally { terminate(); } if (controller.signal.aborted) { return null; } return solution || null; } async function signChallenge(algorithm, parameters, derivedKey, hmacSignatureSecret, hmacKeySignatureSecret) { if (derivedKey && hmacKeySignatureSecret) { parameters.keySignature = bufferToHex( await hmac(algorithm, derivedKey, hmacKeySignatureSecret) ); } parameters = sortKeys(parameters); return { parameters, signature: bufferToHex(await hmac(algorithm, JSON.stringify(parameters), hmacSignatureSecret)) }; } async function deobfuscate(obfuscatedData, options = {}) { let { concurrency = Math.max(1, Math.min(4, navigator.hardwareConcurrency)), createWorker, deriveKey: deriveKey$1 = deriveKey } = options; let challenge = null; try { challenge = JSON.parse(atob(obfuscatedData)); } catch { throw new Error(`Unable to parse obfuscated data.`); } if (!challenge || typeof challenge !== "object" || !("parameters" in challenge) || !("cipher" in challenge)) { throw new Error(`Invalid obfuscated data format.`); } const cipher = challenge.cipher; let solution = null; if (!createWorker && "$altcha" in globalThis) { createWorker = globalThis.$altcha.algorithms.get(challenge.parameters.algorithm); } if (createWorker) { solution = await solveChallengeWorkers({ challenge, concurrency, createWorker }); } else { solution = await solveChallenge({ challenge, deriveKey: deriveKey$1 }); } if (!solution) { throw new Error("Unable to find solution."); } const key = await crypto.subtle.importKey( "raw", hexToBuffer(solution.derivedKey), { name: "AES-GCM" }, false, ["decrypt"] ); const result = await crypto.subtle.decrypt( { name: "AES-GCM", iv: hexToBuffer(cipher.iv) }, key, hexToBuffer(cipher.data) ); return new TextDecoder().decode(result); } async function obfuscate(str, options = {}) { const { deriveKey: deriveKey$1 = deriveKey } = options; const counterMin = options?.counterMin || 20; const counterMax = options?.counterMax || 200; const { parameters } = await createChallenge({ algorithm: "PBKDF2/SHA-256", cost: 5e3, deriveKey: deriveKey$1, counter: Math.floor(Math.random() * (counterMax - counterMin + 1)) + counterMin, keyPrefixLength: 32, ...options }); const key = await crypto.subtle.importKey( "raw", hexToBuffer(parameters.keyPrefix), { name: "AES-GCM" }, false, ["encrypt"] ); const iv = crypto.getRandomValues(new Uint8Array(12)); const data = await crypto.subtle.encrypt( { name: "AES-GCM", iv }, key, new TextEncoder().encode(str) ); return btoa( JSON.stringify({ parameters: { ...parameters, // Return only half the derived key keyPrefix: parameters.keyPrefix.slice(0, parameters.keyLength || 32) }, cipher: { iv: bufferToHex(iv), data: bufferToHex(data) } }) ); } class ObfuscationPlugin extends BasePlugin { static deobfuscate = deobfuscate; static obfuscate = obfuscate; elTrigger = null; activate() { this.elTrigger = this.host.querySelector("button"); if (this.elTrigger) { this.elTrigger.addEventListener("click", this.onTriggerClick.bind(this)); this.host.configure({ floatingAnchor: this.elTrigger }); } } destroy() { } async onVerify(options) { const { minDuration = 500 } = options; const start = performance.now(); const obfuscated = this.host.getAttribute("data-obfuscated"); if (obfuscated) { this.host.reset(State.VERIFYING); try { const text = await deobfuscate(obfuscated); await this.#wait(Math.max(0, minDuration - (performance.now() - start))); this.#renderClearText(text); } catch (err) { this.host.setState(State.ERROR, String(err)); } finally { this.host.setState(State.VERIFIED); } return null; } } onTriggerClick(ev) { ev.preventDefault(); this.host.show(); this.host.verify().then(() => { this.host.hide(); }); } #renderClearText(clearText) { const match = clearText.match(/^(mailto|tel|sms|https?):/); let el; if (match) { const [contact] = clearText.slice(clearText.indexOf(":") + 1).replace(/^\/\//, "").split("?"); el = document.createElement("a"); el.href = clearText; el.innerText = contact; } else { el = document.createTextNode(clearText); } if (this.elTrigger && el) { this.elTrigger.after(el); this.elTrigger.parentElement?.removeChild(this.elTrigger); } } async #wait(ms) { await new Promise((resolve) => setTimeout(resolve, ms)); } } BasePlugin.register(ObfuscationPlugin);