This commit is contained in:
Daniel Regeci
2023-11-18 14:08:48 +04:00
commit 4e86b31592
22 changed files with 3936 additions and 0 deletions

27
.gitignore vendored Normal file
View 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
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}

76
README.md Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

42
package.json Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,5 @@
import Altcha from './Altcha.svelte';
export {
Altcha,
};

38
src/helpers.ts Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1,2 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />

13
src/worker.ts Normal file
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler"
},
"include": ["vite.config.ts"]
}

27
vite.config.ts Normal file
View 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'],
},
});