mirror of
https://github.com/altcha-org/altcha.git
synced 2026-01-24 20:06:40 +00:00
0.1.0
This commit is contained in:
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# app
|
||||
.TODO
|
||||
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["svelte.svelte-vscode"]
|
||||
}
|
||||
76
README.md
Normal file
76
README.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# ALTCHA
|
||||
|
||||
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.
|
||||
|
||||
## Benefits
|
||||
|
||||
- __Friction-less__ - Using PoW instead of visual puzzles.
|
||||
- __Cookie-less__ - GDPR compliant by design.
|
||||
- __Self-hosted__ - Without reliance on external providers.
|
||||
|
||||
## Usage
|
||||
|
||||
ALTCHA widget is distributed as a "Web Component" and [supports all modern browsers](https://developer.mozilla.org/en-US/docs/Web/API/Web_components#browser_compatibility).
|
||||
|
||||
### 1. Add `<script>` tag to your website
|
||||
|
||||
```html
|
||||
<script async defer src="/altcha.js" type="module"></script>
|
||||
```
|
||||
|
||||
### 2. Use `<altcha-box>` tag in your forms
|
||||
|
||||
```html
|
||||
<form>
|
||||
<altcha-box
|
||||
challengeurl="https://..."
|
||||
></altcha-box>
|
||||
</form>
|
||||
```
|
||||
|
||||
See the [configuration](#configuration) below or visit the [website integration documentation](https://altcha.org/docs/website-integration).
|
||||
|
||||
### 3. Integrate ALCTHA with your server
|
||||
|
||||
See [server documentation](https://altcha.org/docs/server-integration) for more details.
|
||||
|
||||
## Configuration
|
||||
|
||||
Main options:
|
||||
|
||||
- __challengeurl__ - The URL of your server, where to fetch the challenge from. See [server integration](/docs/server-integration).
|
||||
- __challengejson__ - The JSON-encoded challenge. If you don't want to make an HTTP request to `challengeurl`, provide the data here instead.
|
||||
|
||||
Customization options:
|
||||
|
||||
- __hidefooter__ - Hide the footer (ALTCHA link).
|
||||
- __hidelogo__ - Hide the ALTCHA logo.
|
||||
- __maxnumber__ - Max. number to iterate to (defaults to 10,000,000).
|
||||
- __name__ - The name of the hidden field containing payload (defaults to "altcha").
|
||||
- __strings__ - JSON-encoded translation strings. See [customization](/docs/widget-customization).
|
||||
|
||||
Development / testing options:
|
||||
|
||||
- __debug__ - Print log messages into the console.
|
||||
- __mockerror__ - Causes the verification to always fail with a "mock" error.
|
||||
- __test__ - This option will make the widget generate its own "mock" challenge, thus __not__ making the request to the `challengeurl`.
|
||||
|
||||
Events:
|
||||
|
||||
- __statechange__ - triggers whenever an internal `state` changes.
|
||||
- __verified__ - triggers when the challenge is verified.
|
||||
|
||||
Using events:
|
||||
|
||||
```js
|
||||
document.querySelector('#altcha').addEventListener('statechange', (ev) => {
|
||||
// state can be: unverified, verifying, verified, error
|
||||
console.log('state:', ev.detail.state);
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
46
example.html
Normal file
46
example.html
Normal file
@@ -0,0 +1,46 @@
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
}
|
||||
main {
|
||||
padding: 1rem;
|
||||
}
|
||||
.buttons {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script async defer src="./dist/altcha.js" type="module"></script>
|
||||
|
||||
<script>
|
||||
window.addEventListener('load', () => {
|
||||
document.querySelector('#altcha').addEventListener('statechange', (ev) => {
|
||||
console.log('State change:', ev.detail);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main>
|
||||
|
||||
<form action="/submit" method="post">
|
||||
|
||||
<altcha-box
|
||||
id="altcha"
|
||||
challengeurl=""
|
||||
debug
|
||||
test
|
||||
></altcha-box>
|
||||
|
||||
<div class="buttons">
|
||||
<button type="submit">Submit</button>
|
||||
<button type="reset">Reset</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + Svelte + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
2936
package-lock.json
generated
Normal file
2936
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
package.json
Normal file
42
package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "altcha",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/altcha-org/altchajs",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/altcha-org/altchajs"
|
||||
},
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"main": "./dist/altcha.umd.cjs",
|
||||
"module": "./dist/altcha.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/altcha.js",
|
||||
"require": "./dist/altcha.umd.cjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "rimraf dist && vite build && echo \"declare module 'altcha';\" > dist/altcha.d.ts",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||
"test": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^2.4.2",
|
||||
"@tsconfig/svelte": "^5.0.0",
|
||||
"body-parser": "^1.20.2",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"svelte": "^4.0.5",
|
||||
"svelte-check": "^3.4.6",
|
||||
"tslib": "^2.6.0",
|
||||
"typescript": "^5.0.2",
|
||||
"vite": "^4.4.5",
|
||||
"vitest": "^0.34.6"
|
||||
}
|
||||
}
|
||||
78
server/server.js
Normal file
78
server/server.js
Normal file
@@ -0,0 +1,78 @@
|
||||
import { createHash, createHmac, randomInt, randomBytes } from 'node:crypto';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import bodyParser from 'body-parser';
|
||||
|
||||
const ALTCHA_ALG = 'SHA-256';
|
||||
const ALTCHA_ALG_NODE = ALTCHA_ALG.replace('-', ''); // node doesn't support alg with dashes
|
||||
const ALTCHA_NUM_RANGE = [10000, 100000]; // adjust complexity
|
||||
const ALTCHA_HMAC_KEY = 'secret.hmac.key'; // !! change this key !!
|
||||
|
||||
function createALTCHA(salt = randomBytes(12).toString('hex'), number = randomInt(...ALTCHA_NUM_RANGE)) {
|
||||
const challenge = createHash(ALTCHA_ALG_NODE).update(salt + number).digest('hex');
|
||||
return {
|
||||
algorithm: ALTCHA_ALG,
|
||||
challenge,
|
||||
salt,
|
||||
signature: createHmac(ALTCHA_ALG_NODE, ALTCHA_HMAC_KEY).update(challenge).digest('hex'),
|
||||
};
|
||||
}
|
||||
|
||||
function verifyALTCHA(payload) {
|
||||
let json;
|
||||
try {
|
||||
json = JSON.parse(atob(payload));
|
||||
} catch {
|
||||
// invalid payload
|
||||
return false;
|
||||
}
|
||||
if (json) {
|
||||
const { algorithm, challenge, salt, signature, number } = json;
|
||||
const check = createALTCHA(salt, number);
|
||||
if (algorithm === check.algorithm && challenge === check.challenge && signature == check.signature) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function url(ref, hash = '') {
|
||||
const url = new URL(ref);
|
||||
url.hash = hash;
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(cors());
|
||||
app.use(bodyParser.urlencoded());
|
||||
|
||||
// Endpoint to fetch a new challenge
|
||||
app.get('/altcha', function (req, res) {
|
||||
res.send(createALTCHA());
|
||||
});
|
||||
|
||||
// Endpoint to handle form submission
|
||||
app.post('/submit', function (req, res) {
|
||||
const ref = req.header('referer');
|
||||
const { altcha } = req.body || {};
|
||||
if (verifyALTCHA(altcha)) {
|
||||
console.log('VERIFIED. Body:', req.body);
|
||||
if (ref) {
|
||||
res.redirect(url(ref, '#success'));
|
||||
} else {
|
||||
res.send('Submitted!');
|
||||
}
|
||||
|
||||
} else {
|
||||
console.log('INVALID ALTCHA. Body:', req.body);
|
||||
if (ref) {
|
||||
res.redirect(url(ref, '#failure'));
|
||||
} else {
|
||||
res.status(403);
|
||||
res.send('ALTCHA check failed.');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(3000);
|
||||
383
src/Altcha.svelte
Normal file
383
src/Altcha.svelte
Normal file
@@ -0,0 +1,383 @@
|
||||
<svelte:options customElement={{
|
||||
tag: 'altcha-box',
|
||||
shadow: 'none',
|
||||
}} />
|
||||
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
|
||||
import InlineWorker from './worker?worker&inline';
|
||||
import { solveChallenge, createTestChallenge } from './helpers';
|
||||
import { State } from './types';
|
||||
import type { Payload, Challenge, Solution } from './types';
|
||||
|
||||
export let challengeurl: string | undefined = undefined;
|
||||
export let challengejson: string | undefined = undefined;
|
||||
export let debug: boolean = false;
|
||||
export let hidefooter: boolean = false;
|
||||
export let name: string = 'altcha';
|
||||
export let maxnumber: number | undefined = undefined;
|
||||
export let mockerror: boolean = false;
|
||||
export let strings: string | undefined = undefined;
|
||||
export let test: boolean = false;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const allowedAlgs = ['SHA-256', 'SHA-384', 'SHA-512'];
|
||||
const website = 'https://altcha.org/';
|
||||
|
||||
let checked: boolean = false;
|
||||
let el: HTMLElement;
|
||||
let elForm: HTMLFormElement | null = null;
|
||||
let error: any = null;
|
||||
let payload: string | null = null;
|
||||
let state: State = State.UNVERIFIED;
|
||||
|
||||
$: parsedChallenge = challengejson ? parseJsonAttribute(challengejson) : undefined;
|
||||
$: parsedStrings = strings ? parseJsonAttribute(strings) : {};
|
||||
$: _strings = {
|
||||
error: 'Verification failed. Try again later.',
|
||||
footer: `Protected by <a href="${website}" target="_blank">ALTCHA</a>`,
|
||||
label: 'I\'m not a robot',
|
||||
verified: 'Verified',
|
||||
verifying: 'Verifying...',
|
||||
waitAlert: 'Verifying... please wait.',
|
||||
...parsedStrings,
|
||||
};
|
||||
$: dispatch('statechange', { state });
|
||||
|
||||
onDestroy(() => {
|
||||
if (elForm) {
|
||||
elForm.removeEventListener('submit', onFormSubmit);
|
||||
elForm.removeEventListener('reset', onFormReset);
|
||||
elForm = null;
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
log('mounted');
|
||||
if (test) {
|
||||
log('using test mode');
|
||||
}
|
||||
elForm = el.closest('form');
|
||||
if (elForm) {
|
||||
elForm.addEventListener('submit', onFormSubmit);
|
||||
elForm.addEventListener('reset', onFormReset);
|
||||
}
|
||||
});
|
||||
|
||||
function log(...args: any[]) {
|
||||
if (debug || args.some((a) => a instanceof Error)) {
|
||||
console[args[0] instanceof Error ? 'error' : 'log']('ALTCHA', ...args);
|
||||
}
|
||||
}
|
||||
|
||||
function onFormSubmit() {
|
||||
reset();
|
||||
}
|
||||
|
||||
function onFormReset() {
|
||||
reset();
|
||||
}
|
||||
|
||||
function parseJsonAttribute(str: string) {
|
||||
return JSON.parse(str);
|
||||
}
|
||||
|
||||
function createAltchaPayload(data: Challenge, solution: Solution): string {
|
||||
return btoa(JSON.stringify({
|
||||
algorithm: data.algorithm,
|
||||
challenge: data!.challenge,
|
||||
number: solution.number,
|
||||
salt: data.salt,
|
||||
signature: data.signature,
|
||||
test: test ? true : undefined,
|
||||
took: solution.took,
|
||||
} satisfies Payload));
|
||||
}
|
||||
|
||||
function validateChallenge(data: Challenge) {
|
||||
if (!data.algorithm || !allowedAlgs.includes(data.algorithm)) {
|
||||
throw new Error(`Unknown algorithm value. Allowed values: ${allowedAlgs.join(', ')}`);
|
||||
}
|
||||
if (!data.challenge || data.challenge.length < 40) {
|
||||
throw new Error('Challenge is too short. Min. 40 chars.');
|
||||
}
|
||||
if (!data.salt || data.salt.length < 10) {
|
||||
throw new Error('Salt is too short. Min. 10 chars.');
|
||||
}
|
||||
if (data.signature === undefined) {
|
||||
throw new Error('Signature is missing.');
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchChallenge(): Promise<Challenge> {
|
||||
if (mockerror) {
|
||||
log('mocking error');
|
||||
throw new Error('Mocked error.');
|
||||
|
||||
} else if (parsedChallenge) {
|
||||
log('using provided json data');
|
||||
return parsedChallenge;
|
||||
|
||||
} else if (test) {
|
||||
log('generating test challenge');
|
||||
return createTestChallenge();
|
||||
|
||||
} else {
|
||||
if (!challengeurl) {
|
||||
throw new Error(`Attribute challengeurl not set.`);
|
||||
}
|
||||
log('fetching challenge from', challengeurl);
|
||||
const resp = await fetch(challengeurl);
|
||||
if (resp.status !== 200) {
|
||||
throw new Error(`Server responded with ${resp.status}.`);
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
}
|
||||
|
||||
async function run(data: Challenge): Promise<{ data: Challenge, solution: Solution | null }> {
|
||||
let solution: Solution | null = null;
|
||||
if ('Worker' in window) {
|
||||
try {
|
||||
solution = await runWorker(data.challenge, data.salt, data.algorithm);
|
||||
} catch (err) {
|
||||
log(err)
|
||||
}
|
||||
if (solution?.number !== undefined) {
|
||||
return {
|
||||
data,
|
||||
solution,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
data,
|
||||
solution: await solveChallenge(data.challenge, data.salt, data.algorithm, maxnumber),
|
||||
}
|
||||
}
|
||||
|
||||
async function runWorker(challenge: string, salt: string, alg?: string): Promise<Solution> {
|
||||
const worker = new InlineWorker();
|
||||
return new Promise((resolve) => {
|
||||
worker.addEventListener('message', (message: MessageEvent) => {
|
||||
resolve(message.data);
|
||||
});
|
||||
worker.postMessage({
|
||||
alg,
|
||||
challenge,
|
||||
max: maxnumber,
|
||||
salt,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function onCheckedChange() {
|
||||
if ([State.UNVERIFIED, State.ERROR].includes(state)) {
|
||||
reset(State.VERIFYING);
|
||||
fetchChallenge()
|
||||
.then((data) => {
|
||||
validateChallenge(data);
|
||||
log('challenge', data);
|
||||
return run(data);
|
||||
})
|
||||
.then(({ data, solution }) => {
|
||||
log('solution', solution);
|
||||
if (solution?.number !== undefined) {
|
||||
log('verified');
|
||||
state = State.VERIFIED;
|
||||
checked = true;
|
||||
payload = createAltchaPayload(data, solution);
|
||||
dispatch('verified', { payload });
|
||||
log('payload', payload);
|
||||
} else {
|
||||
throw new Error('Unexpected result returned.');
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
log(err);
|
||||
state = State.ERROR;
|
||||
checked = false;
|
||||
error = err;
|
||||
});
|
||||
} else {
|
||||
checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
function onInvalid() {
|
||||
if (state === State.VERIFYING) {
|
||||
alert(_strings.waitAlert);
|
||||
}
|
||||
}
|
||||
|
||||
export function reset(newState: State = State.UNVERIFIED) {
|
||||
checked = false;
|
||||
error = null;
|
||||
payload = null;
|
||||
state = newState;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div bind:this={el} class="altcha" data-state={state}>
|
||||
<div class="altcha-main">
|
||||
{#if state === State.VERIFYING}
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
><path
|
||||
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
|
||||
fill="currentColor"
|
||||
opacity=".25"
|
||||
/><path
|
||||
d="M12,4a8,8,0,0,1,7.89,6.7A1.53,1.53,0,0,0,21.38,12h0a1.5,1.5,0,0,0,1.48-1.75,11,11,0,0,0-21.72,0A1.5,1.5,0,0,0,2.62,12h0a1.53,1.53,0,0,0,1.49-1.3A8,8,0,0,1,12,4Z"
|
||||
fill="currentColor"
|
||||
class="altcha-spinner"
|
||||
/></svg
|
||||
>
|
||||
{/if}
|
||||
|
||||
<div class="altcha-checkbox" class:altcha-hidden={state === State.VERIFYING}>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="{name}_checkbox"
|
||||
required
|
||||
bind:checked={checked}
|
||||
on:change={onCheckedChange}
|
||||
on:invalid={onInvalid}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="altcha-label">
|
||||
{#if state === State.VERIFIED}
|
||||
<span>{@html _strings.verified}</span>
|
||||
<input type="hidden" name={name} value={payload} />
|
||||
{:else if state === State.VERIFYING}
|
||||
<span>{@html _strings.verifying}</span>
|
||||
{:else}
|
||||
<label for="{name}_checkbox">{@html _strings.label}</label>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<a href={website} target="_blank" class="altcha-logo">
|
||||
<svg width="22" height="22" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2.33955 16.4279C5.88954 20.6586 12.1971 21.2105 16.4279 17.6604C18.4699 15.947 19.6548 13.5911 19.9352 11.1365L17.9886 10.4279C17.8738 12.5624 16.909 14.6459 15.1423 16.1284C11.7577 18.9684 6.71167 18.5269 3.87164 15.1423C1.03163 11.7577 1.4731 6.71166 4.8577 3.87164C8.24231 1.03162 13.2883 1.4731 16.1284 4.8577C16.9767 5.86872 17.5322 7.02798 17.804 8.2324L19.9522 9.01429C19.7622 7.07737 19.0059 5.17558 17.6604 3.57212C14.1104 -0.658624 7.80283 -1.21043 3.57212 2.33956C-0.658625 5.88958 -1.21046 12.1971 2.33955 16.4279Z" fill="currentColor"/>
|
||||
<path d="M3.57212 2.33956C1.65755 3.94607 0.496389 6.11731 0.12782 8.40523L2.04639 9.13961C2.26047 7.15832 3.21057 5.25375 4.8577 3.87164C8.24231 1.03162 13.2883 1.4731 16.1284 4.8577L13.8302 6.78606L19.9633 9.13364C19.7929 7.15555 19.0335 5.20847 17.6604 3.57212C14.1104 -0.658624 7.80283 -1.21043 3.57212 2.33956Z" fill="currentColor"/>
|
||||
<path d="M7 10H5C5 12.7614 7.23858 15 10 15C12.7614 15 15 12.7614 15 10H13C13 11.6569 11.6569 13 10 13C8.3431 13 7 11.6569 7 10Z" fill="currentColor"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="altcha-error">
|
||||
<svg width="14" height="14" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<div title={error}>{@html _strings.error}</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if _strings.footer && hidefooter !== true}
|
||||
<div class="altcha-footer">
|
||||
<div>{@html _strings.footer}</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style global>
|
||||
.altcha {
|
||||
background: var(--altcha-color-base, #ffffff);
|
||||
border: 1px solid var(--altcha-color-border, #a0a0a0);
|
||||
border-radius: 3px;
|
||||
color: var(--altcha-color-text, currentColor);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 260px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.altcha:focus-within {
|
||||
border-color: var(--altcha-color-border-focus, currentColor);
|
||||
}
|
||||
|
||||
.altcha-main {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
padding: 0.7rem;
|
||||
}
|
||||
|
||||
.altcha-label {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.altcha-label label {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.altcha-logo {
|
||||
color: currentColor;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.altcha-logo:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.altcha-error {
|
||||
color: var(--altcha-color-error-text, #f23939);
|
||||
display: flex;
|
||||
font-size: 0.85rem;
|
||||
gap: 0.3rem;
|
||||
padding: 0 0.7rem 0.7rem;
|
||||
}
|
||||
|
||||
.altcha-footer {
|
||||
align-items: center;
|
||||
background-color: var(--altcha-color-footer-bg, #f4f4f4);
|
||||
display: flex;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.7rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.altcha-footer > *:first-child {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.altcha-footer :global(a) {
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.altcha-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
.altcha-checkbox input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.altcha-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.altcha-spinner {
|
||||
animation: altcha-spinner 0.75s infinite linear;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
@keyframes altcha-spinner {
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
119
src/App.svelte
Normal file
119
src/App.svelte
Normal file
@@ -0,0 +1,119 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import Altcha from "./Altcha.svelte";
|
||||
|
||||
const success = location.hash.includes('success');
|
||||
const failure = location.hash.includes('failure');
|
||||
const params = new URLSearchParams(location.search);
|
||||
|
||||
let challengeurl: string = params.get('challengeurl') || '';
|
||||
let submiturl: string = params.get('submiturl') || '';
|
||||
let test: boolean = !challengeurl && params.get('test') !== '0';
|
||||
let mockerror: boolean = false;
|
||||
|
||||
onMount(() => {
|
||||
location.hash = '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<main>
|
||||
<h1>ALTCHA</h1>
|
||||
|
||||
<div>
|
||||
<label for="challengeUrl">Challenge URL <small>(to fetch the challenge from)</small>:</label>
|
||||
<input type="url" id="challengeUrl" placeholder="http://..." disabled={test} bind:value={challengeurl} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="submitUrl">Submit URL <small>(to submit the data to)</small>:</label>
|
||||
<input type="url" id="submitUrl" placeholder="http://..." disabled={test} bind:value={submiturl} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="testMode">Test mode:</label>
|
||||
<input type="checkbox" id="testMode" bind:checked={test} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="mockError">Mock error:</label>
|
||||
<input type="checkbox" id="mockError" bind:checked={mockerror} />
|
||||
</div>
|
||||
|
||||
<form
|
||||
action={submiturl}
|
||||
method="post"
|
||||
on:submit={(ev) => test ? ev.preventDefault() : undefined}
|
||||
>
|
||||
<div>Test form</div>
|
||||
|
||||
{#if success}
|
||||
<div class="success">
|
||||
Form successfully submitted.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if failure}
|
||||
<div class="failure">
|
||||
Failed to submit form.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<input type="text" name="test_field" placeholder="Test field..." />
|
||||
</div>
|
||||
|
||||
<Altcha
|
||||
debug
|
||||
{challengeurl}
|
||||
{mockerror}
|
||||
{test}
|
||||
on:statechange={(ev) => console.log('Event: statechange:', ev.detail)}
|
||||
on:verified={(ev) => console.log('Event: verified:', ev.detail)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<button type="submit">Submit</button>
|
||||
<button type="reset">Reset</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</main>
|
||||
|
||||
<style lang="scss">
|
||||
main {
|
||||
display: flex;
|
||||
font-family: sans-serif;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin: 6rem auto;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
input:not([type="checkbox"]) {
|
||||
box-sizing: border-box;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
padding: 0.3rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
label + input {
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
form {
|
||||
border: 1px solid #ddd;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: green;
|
||||
}
|
||||
|
||||
.failure {
|
||||
color: red;
|
||||
}
|
||||
</style>
|
||||
5
src/entry.ts
Normal file
5
src/entry.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import Altcha from './Altcha.svelte';
|
||||
|
||||
export {
|
||||
Altcha,
|
||||
};
|
||||
38
src/helpers.ts
Normal file
38
src/helpers.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { Solution } from './types';
|
||||
|
||||
const MAX = 1e7;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
export function ab2hex(ab: ArrayBuffer) {
|
||||
return [...new Uint8Array(ab)].map(x => x.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
export async function createTestChallenge(max: number = 1e5, algorithm: string = 'SHA-256') {
|
||||
const salt = Date.now().toString(16);
|
||||
const challenge = await hashChallenge(salt, Math.round(Math.random() * max), algorithm);
|
||||
return {
|
||||
algorithm,
|
||||
challenge,
|
||||
salt,
|
||||
signature: '',
|
||||
};
|
||||
}
|
||||
|
||||
export async function hashChallenge(salt: string, num: number, algorithm: string) {
|
||||
return ab2hex(await crypto.subtle.digest(algorithm, encoder.encode(salt + num)));
|
||||
}
|
||||
|
||||
export async function solveChallenge(challenge: string, salt: string, algorithm: string = 'SHA-256', max: number = MAX): Promise<Solution | null> {
|
||||
const start = Date.now();
|
||||
for (let i = 0; i <= max; i ++) {
|
||||
const t = await hashChallenge(salt, i, algorithm);
|
||||
if (t === challenge) {
|
||||
return {
|
||||
number: i,
|
||||
took: Date.now() - start,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
7
src/main.ts
Normal file
7
src/main.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import App from './App.svelte'
|
||||
|
||||
const app = new App({
|
||||
target: document.getElementById('app')!,
|
||||
})
|
||||
|
||||
export default app
|
||||
38
src/types.ts
Normal file
38
src/types.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export interface Strings {
|
||||
error: string;
|
||||
footer: string;
|
||||
label: string;
|
||||
verified: string;
|
||||
verifying: string;
|
||||
waitAlert: string;
|
||||
}
|
||||
|
||||
export interface Solution {
|
||||
number: number;
|
||||
took: number;
|
||||
worker?: boolean;
|
||||
}
|
||||
|
||||
export interface Challenge {
|
||||
algorithm: string;
|
||||
challenge: string;
|
||||
salt: string;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
export interface Payload {
|
||||
algorithm: string;
|
||||
challenge: string;
|
||||
number: number;
|
||||
salt: string;
|
||||
signature: string;
|
||||
test?: boolean;
|
||||
took: number;
|
||||
}
|
||||
|
||||
export enum State {
|
||||
ERROR = 'error',
|
||||
VERIFIED = 'verified',
|
||||
VERIFYING = 'verifying',
|
||||
UNVERIFIED = 'unverfied',
|
||||
};
|
||||
2
src/vite-env.d.ts
vendored
Normal file
2
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference types="svelte" />
|
||||
/// <reference types="vite/client" />
|
||||
13
src/worker.ts
Normal file
13
src/worker.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { solveChallenge } from './helpers';
|
||||
|
||||
onmessage = async (message) => {
|
||||
const { alg, challenge, max, salt } = message.data || {};
|
||||
if (challenge && salt) {
|
||||
const solution = await solveChallenge(challenge, salt, alg, max);
|
||||
self.postMessage(solution ? { ...solution, worker: true } : solution);
|
||||
} else {
|
||||
self.postMessage(null);
|
||||
}
|
||||
};
|
||||
|
||||
export {};
|
||||
7
svelte.config.js
Normal file
7
svelte.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
|
||||
|
||||
export default {
|
||||
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
}
|
||||
40
tests/helpers.test.ts
Normal file
40
tests/helpers.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { ab2hex, hashChallenge, createTestChallenge, solveChallenge } from '../src/helpers';
|
||||
|
||||
const alg = 'SHA-256';
|
||||
const salt = 'randomstring';
|
||||
const num = 123;
|
||||
|
||||
describe('ab2hex', () => {
|
||||
test('should return hex-encoded string', () => {
|
||||
expect(ab2hex(new TextEncoder().encode('Hello world'))).toBe('48656c6c6f20776f726c64');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hashChallenge', () => {
|
||||
test('should return a new hex-encoded challenge', async () => {
|
||||
const challenge = await hashChallenge(salt, num, alg);
|
||||
expect(challenge).toBe(ab2hex(await crypto.subtle.digest(alg, new TextEncoder().encode(salt + num))));
|
||||
});
|
||||
});
|
||||
|
||||
describe('createTestChallenge', () => {
|
||||
test('should return a test challenge object', async () => {
|
||||
const data = await createTestChallenge(num, alg);
|
||||
expect(data.algorithm).toBe(alg);
|
||||
expect(data.signature).toBe('');
|
||||
expect(data.salt).toBeDefined();
|
||||
expect(data.challenge).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('solveChallenge', () => {
|
||||
test('should solve challenge and return number', async () => {
|
||||
const data = await createTestChallenge(10, alg);
|
||||
const solution = await solveChallenge(data.challenge, data.salt, data.algorithm);
|
||||
expect(solution?.number).toBeDefined();
|
||||
expect(solution?.took).toBeDefined();
|
||||
const challenge = await hashChallenge(data.salt, solution!.number, alg);
|
||||
expect(data.challenge).toBe(challenge);
|
||||
});
|
||||
});
|
||||
6
tests/setup.ts
Normal file
6
tests/setup.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { webcrypto } from 'node:crypto';
|
||||
|
||||
// https://github.com/jsdom/jsdom/issues/1612
|
||||
Object.defineProperty(globalThis, 'crypto', {
|
||||
value: webcrypto,
|
||||
});
|
||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"resolveJsonModule": true,
|
||||
/**
|
||||
* Typecheck JS in `.svelte` and `.js` files by default.
|
||||
* Disable checkJs if you'd like to use dynamic types in JS.
|
||||
* Note that setting allowJs false does not prevent the use
|
||||
* of JS in `.svelte` files.
|
||||
*/
|
||||
"allowJs": true,
|
||||
"declaration": true,
|
||||
"checkJs": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.svelte"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
9
tsconfig.node.json
Normal file
9
tsconfig.node.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
27
vite.config.ts
Normal file
27
vite.config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/// <reference types="vitest" />
|
||||
import { defineConfig } from "vite";
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
svelte({
|
||||
compilerOptions: {
|
||||
customElement: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
target: "modules",
|
||||
lib: {
|
||||
entry: "src/entry.ts",
|
||||
name: "<<name>>",
|
||||
formats: ["es", "umd"],
|
||||
},
|
||||
minify: true,
|
||||
rollupOptions: {},
|
||||
},
|
||||
test: {
|
||||
setupFiles: ['./tests/setup.ts'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user