mirror of
https://github.com/altcha-org/altcha.git
synced 2026-01-25 04:16:41 +00:00
0.8.0
This commit is contained in:
17
README.md
17
README.md
@@ -2,14 +2,15 @@
|
||||
|
||||
ALTCHA uses a proof-of-work mechanism to protect your website, APIs, and online services from spam and abuse. Unlike other solutions, ALTCHA is self-hosted, does not use cookies nor fingerprinting, does not track users, and is fully compliant with GDPR.
|
||||
|
||||
https://altcha.org
|
||||
Visit [ALTCHA](https://altcha.org) for more information.
|
||||
|
||||
## Benefits
|
||||
## Features
|
||||
|
||||
- __Friction-less__ - Using PoW instead of visual puzzles.
|
||||
- __Cookie-less__ - GDPR compliant by design.
|
||||
- __Self-hosted__ - Without reliance on external providers in self-hosted mode.
|
||||
- __SaaS available__ - Visit [altcha.org](https://altcha.org/docs/api) to get started with the SaaS API.
|
||||
- **Frictionless CAPTCHA Alternative** - Employs proof-of-work (PoW) instead of visual puzzles.
|
||||
- **Data Obfuscation** - Safeguards your email address from scraping.
|
||||
- **Cookie-less** - Designed to be GDPR compliant by default.
|
||||
- **Self-hosted** - Operates independently without relying on external providers.
|
||||
- **SaaS Available** - Get started with the SaaS API at [altcha.org/docs/api](https://altcha.org/docs/api).
|
||||
|
||||
## Examples
|
||||
|
||||
@@ -105,8 +106,9 @@ Additional options:
|
||||
- __hidelogo__ - Hide the ALTCHA logo.
|
||||
- __maxnumber__ - The max. number to iterate to (defaults to 1,000,000).
|
||||
- __name__ - The name of the hidden field containing the payload (defaults to "altcha").
|
||||
- __obfuscated__ - The [obfuscated data](https://altcha.org/docs/obfuscation) provided as a base64-encoded string. Use only without `challengeurl`/`challengejson`.
|
||||
- __spamfilter__ - Enable [Spam Filter](#spam-filter).
|
||||
- __strings__ - JSON-encoded translation strings. Refer to [customization](/docs/widget-customization).
|
||||
- __strings__ - JSON-encoded translation strings. Refer to [customization](https://altcha.org/docs/widget-customization).
|
||||
- __refetchonexpire__ - Automatically re-fetch and re-validate when the challenge expires (defaults to true).
|
||||
- __verifyurl__ - URL for server-side verification requests. This option is automatically configured when the `spamfilter` option is used. Override this setting only if you are using a custom server implementation.
|
||||
- __workers__ - The number of workers to utilize for PoW (defaults to `navigator.hardwareConcurrency || 8`, max. value `16`).
|
||||
@@ -163,6 +165,7 @@ export interface Configure {
|
||||
maxnumber?: number;
|
||||
mockerror?: boolean;
|
||||
name?: string;
|
||||
obfuscated?: string;
|
||||
refetchonexpire?: boolean;
|
||||
spamfilter?: boolean | 'ipAddress' | SpamFilter;
|
||||
strings?: {
|
||||
|
||||
16
dist/altcha.d.ts
vendored
16
dist/altcha.d.ts
vendored
@@ -16,7 +16,7 @@ declare global {
|
||||
|
||||
interface AltchaServerVerificationEvent extends CustomEvent<Record<string, unknown>> {}
|
||||
|
||||
interface AltchaWidget {
|
||||
interface AltchaWidgetOptions {
|
||||
analytics?: boolean | string;
|
||||
auto?: 'onfocus' | 'onload' | 'onsubmit';
|
||||
beaconurl?: string;
|
||||
@@ -31,9 +31,10 @@ declare global {
|
||||
floatingoffset?: number;
|
||||
hidefooter?: boolean;
|
||||
hidelogo?: boolean;
|
||||
name?: string;
|
||||
maxnumber?: number;
|
||||
mockerror?: boolean;
|
||||
name?: string;
|
||||
obfuscated?: string;
|
||||
refetchonexpire?: boolean;
|
||||
spamfilter?: boolean | 'ipAddress';
|
||||
strings?: string;
|
||||
@@ -43,6 +44,16 @@ declare global {
|
||||
workerurl?: string;
|
||||
}
|
||||
|
||||
interface AltchaWidgetMethods {
|
||||
configure: (options: AltchaWidgetOptions) => void;
|
||||
clarify: () => Promise<void>;
|
||||
reset: (newState: AltchaState = 'unverified', err: string | null = null) => void;
|
||||
verify: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface AltchaWidget extends AltchaWidgetOptions extends AltchaWidgetMethods {
|
||||
}
|
||||
|
||||
declare namespace svelteHTML {
|
||||
interface IntrinsicElements {
|
||||
'altcha-widget': AltchaWidgetSvelte;
|
||||
@@ -74,6 +85,7 @@ declare global {
|
||||
}
|
||||
|
||||
interface AltchaWidgetReact extends AltchaWidget extends React.HTMLAttributes<HTMLElement> {
|
||||
children?: React.ReactNode;
|
||||
ref?: React.RefObject<HTMLElement>;
|
||||
style?: AltchaWidgetCSSProperties;
|
||||
}
|
||||
|
||||
1540
dist/altcha.js
vendored
1540
dist/altcha.js
vendored
File diff suppressed because it is too large
Load Diff
4
dist/altcha.umd.cjs
vendored
4
dist/altcha.umd.cjs
vendored
File diff suppressed because one or more lines are too long
16
dist_external/altcha.d.ts
vendored
16
dist_external/altcha.d.ts
vendored
@@ -16,7 +16,7 @@ declare global {
|
||||
|
||||
interface AltchaServerVerificationEvent extends CustomEvent<Record<string, unknown>> {}
|
||||
|
||||
interface AltchaWidget {
|
||||
interface AltchaWidgetOptions {
|
||||
analytics?: boolean | string;
|
||||
auto?: 'onfocus' | 'onload' | 'onsubmit';
|
||||
beaconurl?: string;
|
||||
@@ -31,9 +31,10 @@ declare global {
|
||||
floatingoffset?: number;
|
||||
hidefooter?: boolean;
|
||||
hidelogo?: boolean;
|
||||
name?: string;
|
||||
maxnumber?: number;
|
||||
mockerror?: boolean;
|
||||
name?: string;
|
||||
obfuscated?: string;
|
||||
refetchonexpire?: boolean;
|
||||
spamfilter?: boolean | 'ipAddress';
|
||||
strings?: string;
|
||||
@@ -43,6 +44,16 @@ declare global {
|
||||
workerurl?: string;
|
||||
}
|
||||
|
||||
interface AltchaWidgetMethods {
|
||||
configure: (options: AltchaWidgetOptions) => void;
|
||||
clarify: () => Promise<void>;
|
||||
reset: (newState: AltchaState = 'unverified', err: string | null = null) => void;
|
||||
verify: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface AltchaWidget extends AltchaWidgetOptions extends AltchaWidgetMethods {
|
||||
}
|
||||
|
||||
declare namespace svelteHTML {
|
||||
interface IntrinsicElements {
|
||||
'altcha-widget': AltchaWidgetSvelte;
|
||||
@@ -74,6 +85,7 @@ declare global {
|
||||
}
|
||||
|
||||
interface AltchaWidgetReact extends AltchaWidget extends React.HTMLAttributes<HTMLElement> {
|
||||
children?: React.ReactNode;
|
||||
ref?: React.RefObject<HTMLElement>;
|
||||
style?: AltchaWidgetCSSProperties;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
(function(){"use strict";const f=new TextEncoder;function d(e){return[...new Uint8Array(e)].map(t=>t.toString(16).padStart(2,"0")).join("")}async function p(e,t,o){return d(await crypto.subtle.digest(o.toUpperCase(),f.encode(e+t)))}function g(e,t,o="SHA-256",l=1e6,c=0){const a=new AbortController,i=Date.now();return{promise:(async()=>{for(let n=c;n<=l;n+=1){if(a.signal.aborted)return null;if(await p(t,n,o)===e)return{number:n,took:Date.now()-i}}return null})(),controller:a}}let r;onmessage=async e=>{const{type:t,payload:o}=e.data;if(t==="abort")r==null||r.abort(),r=void 0;else if(t==="work"){const{alg:l,challenge:c,max:a,salt:i,start:u}=o||{},n=g(c,i,l,a,u);r=n.controller,n.promise.then(s=>{self.postMessage(s&&{...s,worker:!0})})}}})();
|
||||
(function(){"use strict";const f=new TextEncoder;function p(e){return[...new Uint8Array(e)].map(t=>t.toString(16).padStart(2,"0")).join("")}async function w(e,t,r){return p(await crypto.subtle.digest(r.toUpperCase(),f.encode(e+t)))}function b(e,t,r="SHA-256",n=1e6,s=0){const o=new AbortController,a=Date.now();return{promise:(async()=>{for(let c=s;c<=n;c+=1){if(o.signal.aborted)return null;if(await w(t,c,r)===e)return{number:c,took:Date.now()-a}}return null})(),controller:o}}function h(e){const t=atob(e),r=new Uint8Array(t.length);for(let n=0;n<t.length;n++)r[n]=t.charCodeAt(n);return r}function g(e,t=12){const r=new Uint8Array(t);for(let n=0;n<t;n++)r[n]=e%256,e=Math.floor(e/256);return r}async function m(e,t="",r=1e6,n=0){const s="AES-GCM",o=new AbortController,a=Date.now(),l=async()=>{for(let u=n;u<=r;u+=1){if(o.signal.aborted||!c||!y)return null;try{const d=await crypto.subtle.decrypt({name:s,iv:g(u)},c,y);if(d)return{clearText:new TextDecoder().decode(d),took:Date.now()-a}}catch{}}return null};let c=null,y=null;try{y=h(e);const u=await crypto.subtle.digest("SHA-256",f.encode(t));c=await crypto.subtle.importKey("raw",u,s,!1,["decrypt"])}catch{return{promise:Promise.reject(),controller:o}}return{promise:l(),controller:o}}let i;onmessage=async e=>{const{type:t,payload:r,start:n,max:s}=e.data;let o=null;if(t==="abort")i==null||i.abort(),i=void 0;else if(t==="work"){if("obfuscated"in r){const{key:a,obfuscated:l}=r||{};o=await m(l,a,s,n)}else{const{algorithm:a,challenge:l,salt:c}=r||{};o=b(l,c,a,s,n)}i=o.controller,o.promise.then(a=>{self.postMessage(a&&{...a,worker:!0})})}}})();
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "altcha",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "altcha",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.44.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "altcha",
|
||||
"description": "GDPR compliant, self-hosted CAPTCHA alternative.",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "Daniel Regeci",
|
||||
|
||||
64
scripts/obfuscate.ts
Normal file
64
scripts/obfuscate.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Run this script with Bun:
|
||||
*
|
||||
* bun run scripts/obfuscate.ts "mailto:..."
|
||||
*
|
||||
* or with Node:
|
||||
*
|
||||
* npx tsx scripts/obfuscate.ts "mailto:..."
|
||||
*/
|
||||
|
||||
const MAX_NUMBER = parseInt(process.env.MAX_NUMBER || '10000', 10);
|
||||
const NUMBER = process.env.NUMBER ? parseInt(process.env.NUMBER || '0', 10) : undefined;
|
||||
const KEY = process.env.KEY || '';
|
||||
|
||||
function randomInt(max: number) {
|
||||
const ab = new Uint32Array(1);
|
||||
crypto.getRandomValues(ab);
|
||||
const randomNumber = ab[0] / (0xffffffff + 1);
|
||||
return Math.floor(randomNumber * max + 1);
|
||||
}
|
||||
|
||||
async function uInt8ArrayToBase64(ua: Uint8Array) {
|
||||
return Buffer.from(ua).toString('base64');
|
||||
}
|
||||
|
||||
function numberToUint8Array(num: number, len: number = 12) {
|
||||
const ua = new Uint8Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
ua[i] = num % 256;
|
||||
num = Math.floor(num / 256);
|
||||
}
|
||||
return ua;
|
||||
}
|
||||
|
||||
async function obfuscateData(
|
||||
raw: string,
|
||||
key: string = '',
|
||||
number: number = NUMBER || randomInt(MAX_NUMBER),
|
||||
) {
|
||||
const encoder = new TextEncoder();
|
||||
const encodedData = encoder.encode(raw);
|
||||
const algorithm = { name: 'AES-GCM', iv: numberToUint8Array(number) };
|
||||
const keyHash = await crypto.subtle.digest(
|
||||
'SHA-256',
|
||||
encoder.encode(key)
|
||||
);
|
||||
const keyData = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyHash,
|
||||
algorithm,
|
||||
false,
|
||||
['encrypt']
|
||||
);
|
||||
const encryptedData = await crypto.subtle.encrypt(
|
||||
algorithm,
|
||||
keyData,
|
||||
encodedData
|
||||
);
|
||||
return uInt8ArrayToBase64(new Uint8Array(encryptedData));
|
||||
}
|
||||
|
||||
console.log(await obfuscateData(process.argv[process.argv.length - 1], KEY));
|
||||
|
||||
export {};
|
||||
@@ -7,7 +7,12 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onDestroy, onMount, tick } from 'svelte';
|
||||
import { solveChallenge, createTestChallenge, getTimeZone } from './helpers';
|
||||
import {
|
||||
solveChallenge,
|
||||
createTestChallenge,
|
||||
getTimeZone,
|
||||
clarifyData,
|
||||
} from './helpers';
|
||||
import { Session } from './session';
|
||||
import { State } from './types';
|
||||
import type {
|
||||
@@ -17,11 +22,13 @@
|
||||
Solution,
|
||||
SpamFilter,
|
||||
ServerVerificationPayload,
|
||||
Obfuscated,
|
||||
ClarifySolution,
|
||||
} from './types';
|
||||
|
||||
export let analytics: boolean = false;
|
||||
export let auto: 'onfocus' | 'onload' | 'onsubmit' | undefined = undefined;
|
||||
export let beaconurl: string | undefined = undefined
|
||||
export let beaconurl: string | undefined = undefined;
|
||||
export let blockspam: boolean | undefined = undefined;
|
||||
export let challengeurl: string | undefined = undefined;
|
||||
export let challengejson: string | undefined = undefined;
|
||||
@@ -43,6 +50,7 @@
|
||||
export let name: string = 'altcha';
|
||||
export let maxnumber: number = 1e6;
|
||||
export let mockerror: boolean = false;
|
||||
export let obfuscated: string | undefined = undefined;
|
||||
export let refetchonexpire: boolean = true;
|
||||
export let spamfilter: boolean | 'ipAddress' | SpamFilter = false;
|
||||
export let strings: string | undefined = undefined;
|
||||
@@ -58,10 +66,12 @@
|
||||
const documentLocale = document.documentElement.lang?.split('-')?.[0];
|
||||
|
||||
let checked: boolean = false;
|
||||
let clarifiedData: string | null = null;
|
||||
let el: HTMLElement;
|
||||
let elAnchorArrow: HTMLElement | null = null;
|
||||
let elFloatingAnchor: HTMLElement | null = null;
|
||||
let elForm: HTMLFormElement | null = null;
|
||||
let elClarifyButton: HTMLElement | null = null;
|
||||
let error: string | null = null;
|
||||
let expireTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let payload: string | null = null;
|
||||
@@ -87,9 +97,13 @@
|
||||
waitAlert: 'Verifying... please wait.',
|
||||
...parsedStrings,
|
||||
};
|
||||
$: dispatch('statechange', { payload, state });
|
||||
$: dispatch(
|
||||
'statechange',
|
||||
clarifiedData ? { clarifiedData, state } : { payload, state }
|
||||
);
|
||||
$: onErrorChange(error);
|
||||
$: onStateChange(state);
|
||||
$: onClarifiedDataChange(clarifiedData);
|
||||
|
||||
onDestroy(() => {
|
||||
if (elForm) {
|
||||
@@ -98,6 +112,9 @@
|
||||
elForm.removeEventListener('focusin', onFormFocusIn);
|
||||
elForm = null;
|
||||
}
|
||||
if (elClarifyButton) {
|
||||
el.removeEventListener('click', onClarifyClick);
|
||||
}
|
||||
if (session) {
|
||||
session.destroy();
|
||||
}
|
||||
@@ -135,11 +152,21 @@
|
||||
elForm.addEventListener('focusin', onFormFocusIn);
|
||||
}
|
||||
}
|
||||
elClarifyButton =
|
||||
el.parentElement?.querySelector('[data-clarify-button]') ||
|
||||
(el.parentElement?.querySelector('button, a') as HTMLElement | null);
|
||||
if (elClarifyButton) {
|
||||
elClarifyButton.addEventListener('click', onClarifyClick);
|
||||
}
|
||||
if (analytics) {
|
||||
enableAnalytics();
|
||||
}
|
||||
if (auto === 'onload') {
|
||||
verify();
|
||||
if (obfuscated) {
|
||||
clarify();
|
||||
} else {
|
||||
verify();
|
||||
}
|
||||
}
|
||||
if (isFreeSaaS && (hidefooter || hidelogo)) {
|
||||
log(
|
||||
@@ -154,6 +181,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
function onClarifyClick(ev: Event) {
|
||||
ev.preventDefault();
|
||||
if (state === State.UNVERIFIED) {
|
||||
clarify();
|
||||
}
|
||||
}
|
||||
|
||||
function onFormFocusIn(ev: FocusEvent) {
|
||||
if (state === State.UNVERIFIED) {
|
||||
verify();
|
||||
@@ -247,9 +281,11 @@
|
||||
}
|
||||
log('fetching challenge from', challengeurl);
|
||||
const resp = await fetch(challengeurl, {
|
||||
headers: !!spamfilter ? {
|
||||
'x-altcha-spam-filter': '1',
|
||||
} : {},
|
||||
headers: !!spamfilter
|
||||
? {
|
||||
'x-altcha-spam-filter': '1',
|
||||
}
|
||||
: {},
|
||||
});
|
||||
if (resp.status !== 200) {
|
||||
throw new Error(`Server responded with ${resp.status}.`);
|
||||
@@ -296,7 +332,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function enableAnalytics () {
|
||||
function enableAnalytics() {
|
||||
if (session) {
|
||||
// already enabled
|
||||
return;
|
||||
@@ -325,28 +361,35 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function run(
|
||||
data: Challenge
|
||||
): Promise<{ data: Challenge; solution: Solution | null }> {
|
||||
let solution: Solution | null = null;
|
||||
async function run(data: Challenge | Obfuscated): Promise<{
|
||||
data: Challenge | Obfuscated;
|
||||
solution: Solution | ClarifySolution | null;
|
||||
}> {
|
||||
let solution: Solution | ClarifySolution | null = null;
|
||||
if ('Worker' in window) {
|
||||
try {
|
||||
solution = await runWorker(
|
||||
data.challenge,
|
||||
data.salt,
|
||||
data.algorithm,
|
||||
data.maxnumber
|
||||
);
|
||||
solution = await runWorker(data, data.maxnumber);
|
||||
} catch (err) {
|
||||
log(err);
|
||||
}
|
||||
if (solution?.number !== undefined) {
|
||||
if (solution?.number !== undefined || 'obfuscated' in data) {
|
||||
return {
|
||||
data,
|
||||
solution,
|
||||
};
|
||||
}
|
||||
}
|
||||
if ('obfuscated' in data) {
|
||||
const solution = await clarifyData(
|
||||
data.obfuscated,
|
||||
data.key,
|
||||
data.maxnumber
|
||||
);
|
||||
return {
|
||||
data,
|
||||
solution: await solution.promise,
|
||||
};
|
||||
}
|
||||
return {
|
||||
data,
|
||||
solution: await solveChallenge(
|
||||
@@ -359,9 +402,7 @@
|
||||
}
|
||||
|
||||
async function runWorker(
|
||||
challenge: string,
|
||||
salt: string,
|
||||
alg?: string,
|
||||
challenge: Challenge | Obfuscated,
|
||||
max: number = typeof test === 'number' ? test : maxnumber,
|
||||
concurrency: number = Math.ceil(workers)
|
||||
): Promise<Solution | null> {
|
||||
@@ -386,13 +427,9 @@
|
||||
resolve(message.data);
|
||||
});
|
||||
worker.postMessage({
|
||||
payload: {
|
||||
alg,
|
||||
challenge,
|
||||
max: start + step,
|
||||
salt,
|
||||
start,
|
||||
},
|
||||
payload: challenge,
|
||||
max: start + step,
|
||||
start,
|
||||
type: 'work',
|
||||
});
|
||||
}) as Promise<Solution | null>;
|
||||
@@ -408,6 +445,8 @@
|
||||
if ([State.UNVERIFIED, State.ERROR, State.EXPIRED].includes(state)) {
|
||||
if (spamfilter && elForm?.reportValidity() === false) {
|
||||
checked = false;
|
||||
} else if (obfuscated) {
|
||||
clarify();
|
||||
} else {
|
||||
verify();
|
||||
}
|
||||
@@ -440,6 +479,28 @@
|
||||
}
|
||||
}
|
||||
|
||||
function onClarifiedDataChange(_: typeof clarifiedData) {
|
||||
if (clarifiedData) {
|
||||
const match = clarifiedData.match(/^(mailto|tel|sms|https?):/);
|
||||
let el: HTMLAnchorElement | Text;
|
||||
if (match) {
|
||||
const [contact] = clarifiedData
|
||||
.slice(clarifiedData.indexOf(':') + 1)
|
||||
.replace(/^\/\//, '')
|
||||
.split('?');
|
||||
el = document.createElement('a');
|
||||
el.href = clarifiedData;
|
||||
el.innerHTML = contact;
|
||||
} else {
|
||||
el = document.createTextNode(clarifiedData);
|
||||
}
|
||||
if (elClarifyButton && el) {
|
||||
elClarifyButton.after(el);
|
||||
elClarifyButton.parentElement?.removeChild(elClarifyButton);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onErrorChange(_: typeof error) {
|
||||
if (session) {
|
||||
session.trackError(error);
|
||||
@@ -622,7 +683,9 @@
|
||||
? document.querySelector(floatinganchor)
|
||||
: elForm?.querySelector(
|
||||
'input[type="submit"], button[type="submit"], button:not([type="button"]):not([type="reset"])'
|
||||
)) || elForm;
|
||||
)) ||
|
||||
elForm ||
|
||||
elClarifyButton;
|
||||
}
|
||||
if (elFloatingAnchor) {
|
||||
// @ts-expect-error
|
||||
@@ -673,10 +736,17 @@
|
||||
analytics = options.analytics;
|
||||
enableAnalytics();
|
||||
}
|
||||
if (options.obfuscated !== undefined) {
|
||||
obfuscated = options.obfuscated;
|
||||
}
|
||||
if (options.auto !== undefined) {
|
||||
auto = options.auto;
|
||||
if (auto === 'onload') {
|
||||
verify();
|
||||
if (obfuscated) {
|
||||
clarify();
|
||||
} else {
|
||||
verify();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (options.beaconurl) {
|
||||
@@ -777,20 +847,22 @@
|
||||
})
|
||||
.then(({ data, solution }) => {
|
||||
log('solution', solution);
|
||||
if (solution?.number !== undefined) {
|
||||
if (verifyurl) {
|
||||
return requestServerVerification(
|
||||
createAltchaPayload(data, solution)
|
||||
);
|
||||
if ('challenge' in data && solution && !('clearText' in solution)) {
|
||||
if (solution?.number !== undefined) {
|
||||
if (verifyurl) {
|
||||
return requestServerVerification(
|
||||
createAltchaPayload(data, solution)
|
||||
);
|
||||
} else {
|
||||
payload = createAltchaPayload(data, solution);
|
||||
log('payload', payload);
|
||||
}
|
||||
} else {
|
||||
payload = createAltchaPayload(data, solution);
|
||||
log('payload', payload);
|
||||
log(
|
||||
"Unable to find a solution. Ensure that the 'maxnumber' attribute is greater than the randomly generated number."
|
||||
);
|
||||
throw new Error('Unexpected result returned.');
|
||||
}
|
||||
} else {
|
||||
log(
|
||||
"Unable to find a solution. Ensure that the 'maxnumber' attribute is greater than the randomly generated number."
|
||||
);
|
||||
throw new Error('Unexpected result returned.');
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
@@ -808,8 +880,45 @@
|
||||
error = err.message;
|
||||
});
|
||||
}
|
||||
|
||||
export async function clarify() {
|
||||
if (!obfuscated) {
|
||||
state = State.ERROR;
|
||||
return;
|
||||
}
|
||||
reset(State.VERIFYING);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay || 0));
|
||||
const [data, params] = obfuscated.split('?');
|
||||
const parsedParams = new URLSearchParams(params || '');
|
||||
let key = parsedParams.get('key') || undefined;
|
||||
if (key) {
|
||||
const match = key.match(/^\(prompt:?(.*)\)$/);
|
||||
if (match) {
|
||||
key = prompt(match[1] || 'Enter Key:') || undefined;
|
||||
}
|
||||
}
|
||||
const { solution } = await run({
|
||||
obfuscated: data,
|
||||
key,
|
||||
maxnumber,
|
||||
});
|
||||
if (solution && 'clearText' in solution) {
|
||||
clarifiedData = solution.clearText;
|
||||
state = State.VERIFIED;
|
||||
checked = true;
|
||||
if (floating && el) {
|
||||
el.style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
state = State.ERROR;
|
||||
checked = false;
|
||||
error = 'Unable to decrypt data.';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<slot />
|
||||
|
||||
<div bind:this={el} class="altcha" data-state={state} data-floating={floating}>
|
||||
<div class="altcha-main">
|
||||
{#if state === State.VERIFYING}
|
||||
@@ -850,7 +959,7 @@
|
||||
<input type="hidden" {name} value={payload} />
|
||||
|
||||
{#if session}
|
||||
<input type="hidden" name="__session" value={sessionPayload} />
|
||||
<input type="hidden" name="__session" value={sessionPayload} />
|
||||
{/if}
|
||||
{:else if state === State.VERIFYING}
|
||||
<span>{@html _strings.verifying}</span>
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
let mockerror: boolean = false;
|
||||
|
||||
let altcha: Altcha;
|
||||
let altchaObfuscated: Altcha;
|
||||
|
||||
onMount(() => {
|
||||
location.hash = '';
|
||||
@@ -100,6 +101,20 @@
|
||||
<button type="reset">Reset</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div>
|
||||
Email:
|
||||
<Altcha
|
||||
bind:this={altchaObfuscated}
|
||||
obfuscated="14tZkC2tFAQSrksIcD3OTD0u4ZWE4VkePJ5d0oVyoGmABDyW9YvNTA=="
|
||||
on:statechange={(ev) => console.log('Event: statechange:', ev.detail)}
|
||||
delay={1500}
|
||||
name="email"
|
||||
floating
|
||||
>
|
||||
<a href="#">(click to reveal)</a>
|
||||
</Altcha>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
16
src/declarations.d.ts
vendored
16
src/declarations.d.ts
vendored
@@ -16,7 +16,7 @@ declare global {
|
||||
|
||||
interface AltchaServerVerificationEvent extends CustomEvent<Record<string, unknown>> {}
|
||||
|
||||
interface AltchaWidget {
|
||||
interface AltchaWidgetOptions {
|
||||
analytics?: boolean | string;
|
||||
auto?: 'onfocus' | 'onload' | 'onsubmit';
|
||||
beaconurl?: string;
|
||||
@@ -31,9 +31,10 @@ declare global {
|
||||
floatingoffset?: number;
|
||||
hidefooter?: boolean;
|
||||
hidelogo?: boolean;
|
||||
name?: string;
|
||||
maxnumber?: number;
|
||||
mockerror?: boolean;
|
||||
name?: string;
|
||||
obfuscated?: string;
|
||||
refetchonexpire?: boolean;
|
||||
spamfilter?: boolean | 'ipAddress';
|
||||
strings?: string;
|
||||
@@ -43,6 +44,16 @@ declare global {
|
||||
workerurl?: string;
|
||||
}
|
||||
|
||||
interface AltchaWidgetMethods {
|
||||
configure: (options: AltchaWidgetOptions) => void;
|
||||
clarify: () => Promise<void>;
|
||||
reset: (newState: AltchaState = 'unverified', err: string | null = null) => void;
|
||||
verify: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface AltchaWidget extends AltchaWidgetOptions extends AltchaWidgetMethods {
|
||||
}
|
||||
|
||||
declare namespace svelteHTML {
|
||||
interface IntrinsicElements {
|
||||
'altcha-widget': AltchaWidgetSvelte;
|
||||
@@ -74,6 +85,7 @@ declare global {
|
||||
}
|
||||
|
||||
interface AltchaWidgetReact extends AltchaWidget extends React.HTMLAttributes<HTMLElement> {
|
||||
children?: React.ReactNode;
|
||||
ref?: React.RefObject<HTMLElement>;
|
||||
style?: AltchaWidgetCSSProperties;
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export function solveChallenge(
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
return {
|
||||
promise: fn(),
|
||||
controller,
|
||||
@@ -78,3 +78,83 @@ export function getTimeZone() {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function base64ToUint8Array(encoded: string) {
|
||||
const str = atob(encoded);
|
||||
const ua = new Uint8Array(str.length);
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
ua[i] = str.charCodeAt(i);
|
||||
}
|
||||
return ua;
|
||||
}
|
||||
|
||||
export function numberToUint8Array(num: number, len: number = 12) {
|
||||
const ua = new Uint8Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
ua[i] = num % 256;
|
||||
num = Math.floor(num / 256);
|
||||
}
|
||||
return ua;
|
||||
}
|
||||
|
||||
export async function clarifyData(
|
||||
encrypted: string,
|
||||
key: string = '',
|
||||
max: number = 1e6,
|
||||
start: number = 0
|
||||
) {
|
||||
const algorithm = 'AES-GCM';
|
||||
const controller = new AbortController();
|
||||
const startTime = Date.now();
|
||||
const fn = async () => {
|
||||
for (let n = start; n <= max; n += 1) {
|
||||
if (controller.signal.aborted || !cryptoKey || !encryptedData) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const decryptedData = await crypto.subtle.decrypt(
|
||||
{
|
||||
name: algorithm,
|
||||
iv: numberToUint8Array(n),
|
||||
},
|
||||
cryptoKey,
|
||||
encryptedData,
|
||||
);
|
||||
if (decryptedData) {
|
||||
return {
|
||||
clearText: new TextDecoder().decode(decryptedData),
|
||||
took: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
let cryptoKey: CryptoKey | null = null;
|
||||
let encryptedData: Uint8Array | null = null;
|
||||
try {
|
||||
encryptedData = base64ToUint8Array(encrypted);
|
||||
const keyHash = await crypto.subtle.digest(
|
||||
'SHA-256',
|
||||
encoder.encode(key)
|
||||
);
|
||||
cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyHash,
|
||||
algorithm,
|
||||
false,
|
||||
['decrypt']
|
||||
);
|
||||
} catch {
|
||||
return {
|
||||
promise: Promise.reject(),
|
||||
controller,
|
||||
};
|
||||
}
|
||||
return {
|
||||
promise: fn(),
|
||||
controller,
|
||||
};
|
||||
}
|
||||
13
src/types.ts
13
src/types.ts
@@ -26,6 +26,7 @@ export interface Configure {
|
||||
maxnumber?: number;
|
||||
mockerror?: boolean;
|
||||
name?: string;
|
||||
obfuscated?: string;
|
||||
refetchonexpire?: boolean;
|
||||
spamfilter?: boolean | 'ipAddress' | SpamFilter;
|
||||
strings?: Partial<Strings>;
|
||||
@@ -86,6 +87,18 @@ export interface Payload {
|
||||
took: number;
|
||||
}
|
||||
|
||||
export interface Obfuscated {
|
||||
obfuscated: string;
|
||||
key?: string;
|
||||
maxnumber?: number;
|
||||
}
|
||||
|
||||
export interface ClarifySolution {
|
||||
clearText: string;
|
||||
took: number;
|
||||
worker?: boolean;
|
||||
}
|
||||
|
||||
export enum State {
|
||||
ERROR = 'error',
|
||||
VERIFIED = 'verified',
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { solveChallenge } from './helpers';
|
||||
import { solveChallenge, clarifyData } from './helpers';
|
||||
|
||||
let controller: AbortController | undefined = undefined;
|
||||
|
||||
onmessage = async (message) => {
|
||||
const { type, payload } = message.data;
|
||||
const { type, payload, start, max } = message.data;
|
||||
let result: ReturnType<typeof solveChallenge> | Awaited<ReturnType<typeof clarifyData>> | null = null;
|
||||
if (type === 'abort') {
|
||||
controller?.abort();
|
||||
controller = undefined;
|
||||
} else if (type === 'work') {
|
||||
const { alg, challenge, max, salt, start } = payload || {};
|
||||
const result = solveChallenge(challenge, salt, alg, max, start);
|
||||
if ('obfuscated' in payload) {
|
||||
const { key, obfuscated } = payload || {};
|
||||
result = await clarifyData(obfuscated, key, max, start);
|
||||
|
||||
} else {
|
||||
const { algorithm, challenge, salt } = payload || {};
|
||||
result = solveChallenge(challenge, salt, algorithm, max, start);
|
||||
}
|
||||
controller = result.controller;
|
||||
result.promise.then((solution) => {
|
||||
self.postMessage(solution ? { ...solution, worker: true } : solution);
|
||||
|
||||
Reference in New Issue
Block a user