mirror of
https://github.com/altcha-org/altcha-lib.git
synced 2026-01-24 20:08:44 +00:00
0.1.0
This commit is contained in:
20
.eslintrc.json
Normal file
20
.eslintrc.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": [
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"rules": {
|
||||
}
|
||||
}
|
||||
42
.github/workflows/ci.yml
vendored
Normal file
42
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
name: ci
|
||||
on:
|
||||
push:
|
||||
branches: [main, next]
|
||||
pull_request:
|
||||
branches: ['*']
|
||||
|
||||
jobs:
|
||||
deno:
|
||||
name: 'Deno'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: denoland/setup-deno@v1
|
||||
with:
|
||||
deno-version: v1.x
|
||||
- run: npm run test:deno
|
||||
|
||||
bun:
|
||||
name: 'Bun'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: '1.0.26'
|
||||
- run: bun install
|
||||
- run: bun test
|
||||
|
||||
node:
|
||||
name: 'Node.js v${{ matrix.node }}'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node: ['16.x', '18.x', '20.x']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
- run: npm install --frozen-lockfile
|
||||
- run: npm run test
|
||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/node_modules
|
||||
.TODO
|
||||
6
.prettierrc
Normal file
6
.prettierrc
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"deno.enable": false
|
||||
}
|
||||
128
CODE_OF_CONDUCT.md
Normal file
128
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
GitHub Issues.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
40
CONTRIBUTING.md
Normal file
40
CONTRIBUTING.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Contributing to ALTCHA
|
||||
|
||||
We appreciate your contributions! To ensure a smooth and transparent collaboration, we've outlined the various ways you can contribute to ALTCHA:
|
||||
|
||||
- **Reporting a Bug**
|
||||
- **Discussing the Current State of the Code**
|
||||
- **Submitting a Fix**
|
||||
- **Proposing New Features**
|
||||
- **Becoming a Maintainer**
|
||||
|
||||
## Development with GitHub
|
||||
|
||||
ALTCHA is hosted on GitHub, where you can find the codebase, track issues, and submit pull requests. Please familiarize yourself with GitHub for effective collaboration.
|
||||
|
||||
## Project Technology: Svelte
|
||||
|
||||
ALTCHA utilizes [Svelte](https://svelte.dev) for its Web Component widget. Refer to Svelte's documentation to set up your development environment.
|
||||
|
||||
## Licensing Information
|
||||
|
||||
Any contributions you make will be subjected to the project's MIT software license. By submitting code changes, you agree to license your contributions under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the entire project. If you have any concerns regarding licensing, feel free to reach out to the maintainers.
|
||||
|
||||
## Reporting Bugs Using GitHub's [Issues](https://github.com/altcha-org/altcha/issues)
|
||||
|
||||
We track public bugs using GitHub issues. Reporting a bug is easy: simply [open a new issue](https://github.com/altcha-org/altcha/issues). Provide detailed information for effective bug resolution.
|
||||
|
||||
## Writing Effective Bug Reports
|
||||
|
||||
Good bug reports include:
|
||||
|
||||
- A quick summary and background of the issue
|
||||
- Steps to reproduce the problem
|
||||
- Be specific!
|
||||
- Include sample code if possible
|
||||
- Expected vs. actual outcomes
|
||||
- Additional notes, such as your hypotheses or unsuccessful attempts to resolve the issue
|
||||
|
||||
## License Agreement
|
||||
|
||||
By contributing to ALTCHA, you agree that your contributions will be licensed under the project's MIT License. If you have any questions or concerns, please reach out to the maintainers.
|
||||
21
LICENSE.txt
Normal file
21
LICENSE.txt
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Daniel Regeci
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
54
README.md
Normal file
54
README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# ALTCHA JS Library
|
||||
|
||||
ALTCHA JS Library is a lightweight, zero-dependency library designed for creating and verifying [ALTCHA](https://altcha.org) challenges specifically tailored for Node.js, Bun, and Deno environments.
|
||||
|
||||
## Compatibility
|
||||
|
||||
This library utilizes [Web Crypto](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto) and is intended for server-side use.
|
||||
|
||||
- Node.js 16+
|
||||
- Bun 1+
|
||||
- Deno 1+
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { createChallenge, verifySolution } from 'altcha-lib';
|
||||
|
||||
const hmacKey = 'secret hmac key';
|
||||
|
||||
// Create a new challenge and send it to the client:
|
||||
const challenge = await createChallenge({
|
||||
hmacKey,
|
||||
});
|
||||
|
||||
// When submitted, verify the payload:
|
||||
const ok = await verifySolution(payload, hmacKey);
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### `createChallenge(options)`
|
||||
|
||||
Creates a new challenge for ALTCHA.
|
||||
|
||||
Parameters:
|
||||
|
||||
- `options: ChallengeOptions`:
|
||||
- `algorithm?: string`: Algorithm to use (`SHA-1`, `SHA-256`, `SHA-512`, default: `SHA-256`).
|
||||
- `hmacKey: string` (required): Signature HMAC key.
|
||||
- `number?: number`: Optional number to use. If not provided, a random number will be generated.
|
||||
- `salt?: string`: Optional salt string. If not provided, a random salt will be generated.
|
||||
|
||||
### `verifySolution(payload, hmacKey)`
|
||||
|
||||
Verifies an ALTCHA solution. The payload can be a Base64-encoded JSON payload (as submitted by the widget) or an object.
|
||||
|
||||
Parameters:
|
||||
|
||||
- `payload: string | Payload`
|
||||
- `hmacKey: string`
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
54
deno_dist/README.md
Normal file
54
deno_dist/README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# ALTCHA JS Library
|
||||
|
||||
ALTCHA JS Library is a lightweight, zero-dependency library designed for creating and verifying [ALTCHA](https://altcha.org) challenges specifically tailored for Node.js, Bun, and Deno environments.
|
||||
|
||||
## Compatibility
|
||||
|
||||
This library utilizes [Web Crypto](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto) and is intended for server-side use.
|
||||
|
||||
- Node.js 16+
|
||||
- Bun 1+
|
||||
- Deno 1+
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { createChallenge, verifySolution } from 'altcha-lib';
|
||||
|
||||
const hmacKey = 'secret hmac key';
|
||||
|
||||
// Create a new challenge and send it to the client:
|
||||
const challenge = await createChallenge({
|
||||
hmacKey,
|
||||
});
|
||||
|
||||
// When submitted, verify the payload:
|
||||
const ok = await verifySolution(payload, hmacKey);
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### `createChallenge(options)`
|
||||
|
||||
Creates a new challenge for ALTCHA.
|
||||
|
||||
Parameters:
|
||||
|
||||
- `options: ChallengeOptions`:
|
||||
- `algorithm?: string`: Algorithm to use (`SHA-1`, `SHA-256`, `SHA-512`, default: `SHA-256`).
|
||||
- `hmacKey: string` (required): Signature HMAC key.
|
||||
- `number?: number`: Optional number to use. If not provided, a random number will be generated.
|
||||
- `salt?: string`: Optional salt string. If not provided, a random salt will be generated.
|
||||
|
||||
### `verifySolution(payload, hmacKey)`
|
||||
|
||||
Verifies an ALTCHA solution. The payload can be a Base64-encoded JSON payload (as submitted by the widget) or an object.
|
||||
|
||||
Parameters:
|
||||
|
||||
- `payload: string | Payload`
|
||||
- `hmacKey: string`
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
47
deno_dist/helpers.ts
Normal file
47
deno_dist/helpers.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { Algorithm } from './types.ts';
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
if (!('crypto' in globalThis)) {
|
||||
// @ts-ignore
|
||||
globalThis.crypto = (await import('node:crypto')).webcrypto;
|
||||
}
|
||||
|
||||
export function ab2hex(ab: ArrayBuffer | Uint8Array) {
|
||||
return [...new Uint8Array(ab)]
|
||||
.map((x) => x.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
export async function hash(algorithm: Algorithm, str: string) {
|
||||
return ab2hex(
|
||||
await crypto.subtle.digest(algorithm.toUpperCase(), encoder.encode(str))
|
||||
);
|
||||
}
|
||||
|
||||
export async function hmac(algorithm: Algorithm, str: string, secret: string) {
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
{
|
||||
name: 'HMAC',
|
||||
hash: algorithm,
|
||||
},
|
||||
false,
|
||||
['sign', 'verify']
|
||||
);
|
||||
return ab2hex(await crypto.subtle.sign('HMAC', key, encoder.encode(str)));
|
||||
}
|
||||
|
||||
export function randomBytes(length: number) {
|
||||
const ab = new Uint8Array(length);
|
||||
crypto.getRandomValues(ab);
|
||||
return ab;
|
||||
}
|
||||
|
||||
export function randomInt(max: number) {
|
||||
const ab = new Uint32Array(1);
|
||||
crypto.getRandomValues(ab);
|
||||
const randomNumber = ab[0] / (0xffffffff + 1);
|
||||
return Math.floor(randomNumber * max + 1);
|
||||
}
|
||||
45
deno_dist/index.ts
Normal file
45
deno_dist/index.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { ab2hex, hash, hmac, randomBytes, randomInt } from './helpers.ts';
|
||||
import type {
|
||||
Algorithm,
|
||||
Challenge,
|
||||
ChallengeOptions,
|
||||
Payload,
|
||||
} from './types.ts';
|
||||
|
||||
const DEFAULT_MAX_NUMBER = 1e7;
|
||||
const DEFAULT_ALG: Algorithm = 'SHA-256';
|
||||
|
||||
export async function createChallenge(
|
||||
options: ChallengeOptions
|
||||
): Promise<Challenge> {
|
||||
const algorithm = options.algorithm || DEFAULT_ALG;
|
||||
const salt = options.salt || ab2hex(randomBytes(12));
|
||||
const number =
|
||||
options.number === void 0 ? randomInt(DEFAULT_MAX_NUMBER) : options.number;
|
||||
const challenge = await hash(algorithm, salt + number);
|
||||
return {
|
||||
algorithm,
|
||||
challenge,
|
||||
salt,
|
||||
signature: await hmac(algorithm, challenge, options.hmacKey),
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifySolution(
|
||||
payload: string | Payload,
|
||||
hmacKey: string
|
||||
) {
|
||||
if (typeof payload === 'string') {
|
||||
payload = JSON.parse(atob(payload)) as Payload;
|
||||
}
|
||||
const check = await createChallenge({
|
||||
algorithm: payload.algorithm,
|
||||
hmacKey,
|
||||
number: payload.number,
|
||||
salt: payload.salt,
|
||||
});
|
||||
return (
|
||||
check.challenge === payload.challenge &&
|
||||
check.signature === payload.signature
|
||||
);
|
||||
}
|
||||
1
deno_dist/mod.ts
Normal file
1
deno_dist/mod.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./index.ts";
|
||||
23
deno_dist/types.ts
Normal file
23
deno_dist/types.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export type Algorithm = 'SHA-1' | 'SHA-256' | 'SHA-512';
|
||||
|
||||
export interface Challenge {
|
||||
algorithm: Algorithm;
|
||||
challenge: string;
|
||||
salt: string;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
export interface ChallengeOptions {
|
||||
algorithm?: Algorithm;
|
||||
hmacKey: string;
|
||||
number?: number;
|
||||
salt?: string;
|
||||
}
|
||||
|
||||
export interface Payload {
|
||||
algorithm: Algorithm;
|
||||
challenge: string;
|
||||
number: number;
|
||||
salt: string;
|
||||
signature: string;
|
||||
}
|
||||
6
dist/helpers.d.ts
vendored
Normal file
6
dist/helpers.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { Algorithm } from './types.js';
|
||||
export declare function ab2hex(ab: ArrayBuffer | Uint8Array): string;
|
||||
export declare function hash(algorithm: Algorithm, str: string): Promise<string>;
|
||||
export declare function hmac(algorithm: Algorithm, str: string, secret: string): Promise<string>;
|
||||
export declare function randomBytes(length: number): Uint8Array;
|
||||
export declare function randomInt(max: number): number;
|
||||
31
dist/helpers.js
vendored
Normal file
31
dist/helpers.js
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
const encoder = new TextEncoder();
|
||||
if (!('crypto' in globalThis)) {
|
||||
// @ts-ignore
|
||||
globalThis.crypto = (await import('node:crypto')).webcrypto;
|
||||
}
|
||||
export function ab2hex(ab) {
|
||||
return [...new Uint8Array(ab)]
|
||||
.map((x) => x.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
export async function hash(algorithm, str) {
|
||||
return ab2hex(await crypto.subtle.digest(algorithm.toUpperCase(), encoder.encode(str)));
|
||||
}
|
||||
export async function hmac(algorithm, str, secret) {
|
||||
const key = await crypto.subtle.importKey('raw', encoder.encode(secret), {
|
||||
name: 'HMAC',
|
||||
hash: algorithm,
|
||||
}, false, ['sign', 'verify']);
|
||||
return ab2hex(await crypto.subtle.sign('HMAC', key, encoder.encode(str)));
|
||||
}
|
||||
export function randomBytes(length) {
|
||||
const ab = new Uint8Array(length);
|
||||
crypto.getRandomValues(ab);
|
||||
return ab;
|
||||
}
|
||||
export function randomInt(max) {
|
||||
const ab = new Uint32Array(1);
|
||||
crypto.getRandomValues(ab);
|
||||
const randomNumber = ab[0] / (0xffffffff + 1);
|
||||
return Math.floor(randomNumber * max + 1);
|
||||
}
|
||||
3
dist/index.d.ts
vendored
Normal file
3
dist/index.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { Challenge, ChallengeOptions, Payload } from './types.js';
|
||||
export declare function createChallenge(options: ChallengeOptions): Promise<Challenge>;
|
||||
export declare function verifySolution(payload: string | Payload, hmacKey: string): Promise<boolean>;
|
||||
28
dist/index.js
vendored
Normal file
28
dist/index.js
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
import { ab2hex, hash, hmac, randomBytes, randomInt } from './helpers.js';
|
||||
const DEFAULT_MAX_NUMBER = 1e7;
|
||||
const DEFAULT_ALG = 'SHA-256';
|
||||
export async function createChallenge(options) {
|
||||
const algorithm = options.algorithm || DEFAULT_ALG;
|
||||
const salt = options.salt || ab2hex(randomBytes(12));
|
||||
const number = options.number === void 0 ? randomInt(DEFAULT_MAX_NUMBER) : options.number;
|
||||
const challenge = await hash(algorithm, salt + number);
|
||||
return {
|
||||
algorithm,
|
||||
challenge,
|
||||
salt,
|
||||
signature: await hmac(algorithm, challenge, options.hmacKey),
|
||||
};
|
||||
}
|
||||
export async function verifySolution(payload, hmacKey) {
|
||||
if (typeof payload === 'string') {
|
||||
payload = JSON.parse(atob(payload));
|
||||
}
|
||||
const check = await createChallenge({
|
||||
algorithm: payload.algorithm,
|
||||
hmacKey,
|
||||
number: payload.number,
|
||||
salt: payload.salt,
|
||||
});
|
||||
return (check.challenge === payload.challenge &&
|
||||
check.signature === payload.signature);
|
||||
}
|
||||
20
dist/types.d.ts
vendored
Normal file
20
dist/types.d.ts
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
export type Algorithm = 'SHA-1' | 'SHA-256' | 'SHA-512';
|
||||
export interface Challenge {
|
||||
algorithm: Algorithm;
|
||||
challenge: string;
|
||||
salt: string;
|
||||
signature: string;
|
||||
}
|
||||
export interface ChallengeOptions {
|
||||
algorithm?: Algorithm;
|
||||
hmacKey: string;
|
||||
number?: number;
|
||||
salt?: string;
|
||||
}
|
||||
export interface Payload {
|
||||
algorithm: Algorithm;
|
||||
challenge: string;
|
||||
number: number;
|
||||
salt: string;
|
||||
signature: string;
|
||||
}
|
||||
1
dist/types.js
vendored
Normal file
1
dist/types.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export {};
|
||||
47
lib/helpers.ts
Normal file
47
lib/helpers.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { Algorithm } from './types.js';
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
if (!('crypto' in globalThis)) {
|
||||
// @ts-ignore
|
||||
globalThis.crypto = (await import('node:crypto')).webcrypto;
|
||||
}
|
||||
|
||||
export function ab2hex(ab: ArrayBuffer | Uint8Array) {
|
||||
return [...new Uint8Array(ab)]
|
||||
.map((x) => x.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
export async function hash(algorithm: Algorithm, str: string) {
|
||||
return ab2hex(
|
||||
await crypto.subtle.digest(algorithm.toUpperCase(), encoder.encode(str))
|
||||
);
|
||||
}
|
||||
|
||||
export async function hmac(algorithm: Algorithm, str: string, secret: string) {
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
{
|
||||
name: 'HMAC',
|
||||
hash: algorithm,
|
||||
},
|
||||
false,
|
||||
['sign', 'verify']
|
||||
);
|
||||
return ab2hex(await crypto.subtle.sign('HMAC', key, encoder.encode(str)));
|
||||
}
|
||||
|
||||
export function randomBytes(length: number) {
|
||||
const ab = new Uint8Array(length);
|
||||
crypto.getRandomValues(ab);
|
||||
return ab;
|
||||
}
|
||||
|
||||
export function randomInt(max: number) {
|
||||
const ab = new Uint32Array(1);
|
||||
crypto.getRandomValues(ab);
|
||||
const randomNumber = ab[0] / (0xffffffff + 1);
|
||||
return Math.floor(randomNumber * max + 1);
|
||||
}
|
||||
45
lib/index.ts
Normal file
45
lib/index.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { ab2hex, hash, hmac, randomBytes, randomInt } from './helpers.js';
|
||||
import type {
|
||||
Algorithm,
|
||||
Challenge,
|
||||
ChallengeOptions,
|
||||
Payload,
|
||||
} from './types.js';
|
||||
|
||||
const DEFAULT_MAX_NUMBER = 1e7;
|
||||
const DEFAULT_ALG: Algorithm = 'SHA-256';
|
||||
|
||||
export async function createChallenge(
|
||||
options: ChallengeOptions
|
||||
): Promise<Challenge> {
|
||||
const algorithm = options.algorithm || DEFAULT_ALG;
|
||||
const salt = options.salt || ab2hex(randomBytes(12));
|
||||
const number =
|
||||
options.number === void 0 ? randomInt(DEFAULT_MAX_NUMBER) : options.number;
|
||||
const challenge = await hash(algorithm, salt + number);
|
||||
return {
|
||||
algorithm,
|
||||
challenge,
|
||||
salt,
|
||||
signature: await hmac(algorithm, challenge, options.hmacKey),
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifySolution(
|
||||
payload: string | Payload,
|
||||
hmacKey: string
|
||||
) {
|
||||
if (typeof payload === 'string') {
|
||||
payload = JSON.parse(atob(payload)) as Payload;
|
||||
}
|
||||
const check = await createChallenge({
|
||||
algorithm: payload.algorithm,
|
||||
hmacKey,
|
||||
number: payload.number,
|
||||
salt: payload.salt,
|
||||
});
|
||||
return (
|
||||
check.challenge === payload.challenge &&
|
||||
check.signature === payload.signature
|
||||
);
|
||||
}
|
||||
23
lib/types.ts
Normal file
23
lib/types.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export type Algorithm = 'SHA-1' | 'SHA-256' | 'SHA-512';
|
||||
|
||||
export interface Challenge {
|
||||
algorithm: Algorithm;
|
||||
challenge: string;
|
||||
salt: string;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
export interface ChallengeOptions {
|
||||
algorithm?: Algorithm;
|
||||
hmacKey: string;
|
||||
number?: number;
|
||||
salt?: string;
|
||||
}
|
||||
|
||||
export interface Payload {
|
||||
algorithm: Algorithm;
|
||||
challenge: string;
|
||||
number: number;
|
||||
salt: string;
|
||||
signature: string;
|
||||
}
|
||||
4293
package-lock.json
generated
Normal file
4293
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
53
package.json
Normal file
53
package.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "altcha-lib",
|
||||
"version": "0.1.0",
|
||||
"description": "A library for creating and verifying ALTCHA challenges for Node.js, Bun and Deno.",
|
||||
"author": "Daniel Regeci",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"altcha",
|
||||
"captcha",
|
||||
"antispam",
|
||||
"captcha alternative"
|
||||
],
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "rimraf dist && tsc -p tsconfig.build.json",
|
||||
"denoify": "rimraf deno_dist && denoify && find deno_dist/. -type f -exec sed -i '' -e 's/node:node:/node:/g' {} +",
|
||||
"eslint": "eslint ./lib/**/*",
|
||||
"format": "prettier --write './(lib|tests)/**/*'",
|
||||
"test": "vitest",
|
||||
"test:deno": "deno test tests/deno.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"types": [
|
||||
"./dist/types"
|
||||
]
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.9.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"denoify": "^1.6.9",
|
||||
"eslint": "^8.56.0",
|
||||
"prettier": "^3.2.5",
|
||||
"rimraf": "^5.0.5",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vitest": "^1.0.1"
|
||||
}
|
||||
}
|
||||
171
tests/challenge.test.ts
Normal file
171
tests/challenge.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createChallenge, verifySolution } from '../lib/index.js';
|
||||
import { Challenge } from '../lib/types.js';
|
||||
|
||||
describe('challenge', () => {
|
||||
const hmacKey = 'test key';
|
||||
|
||||
describe('createChallenge()', () => {
|
||||
it('should return a new challenge object with default algorithm', async () => {
|
||||
const challenge = await createChallenge({
|
||||
hmacKey,
|
||||
});
|
||||
expect(challenge).toEqual({
|
||||
algorithm: 'SHA-256',
|
||||
challenge: expect.any(String),
|
||||
salt: expect.any(String),
|
||||
signature: expect.any(String),
|
||||
} satisfies Challenge);
|
||||
expect(challenge.salt.length).toEqual(24);
|
||||
expect(challenge.challenge.length).toEqual(64);
|
||||
expect(challenge.signature.length).toEqual(64);
|
||||
});
|
||||
|
||||
it('should return a new challenge object with SHA-1', async () => {
|
||||
const challenge = await createChallenge({
|
||||
algorithm: 'SHA-1',
|
||||
hmacKey,
|
||||
});
|
||||
expect(challenge).toEqual({
|
||||
algorithm: 'SHA-1',
|
||||
challenge: expect.any(String),
|
||||
salt: expect.any(String),
|
||||
signature: expect.any(String),
|
||||
} satisfies Challenge);
|
||||
expect(challenge.salt.length).toEqual(24);
|
||||
expect(challenge.challenge.length).toEqual(40);
|
||||
expect(challenge.signature.length).toEqual(40);
|
||||
});
|
||||
|
||||
it('should return a new challenge object with SHA-512', async () => {
|
||||
const challenge = await createChallenge({
|
||||
algorithm: 'SHA-512',
|
||||
hmacKey,
|
||||
});
|
||||
expect(challenge).toEqual({
|
||||
algorithm: 'SHA-512',
|
||||
challenge: expect.any(String),
|
||||
salt: expect.any(String),
|
||||
signature: expect.any(String),
|
||||
} satisfies Challenge);
|
||||
expect(challenge.salt.length).toEqual(24);
|
||||
expect(challenge.challenge.length).toEqual(128);
|
||||
expect(challenge.signature.length).toEqual(128);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifySolution()', () => {
|
||||
it('should return true', async () => {
|
||||
const number = 100;
|
||||
const challenge = await createChallenge({
|
||||
number,
|
||||
hmacKey,
|
||||
});
|
||||
const ok = await verifySolution(
|
||||
{
|
||||
algorithm: challenge.algorithm,
|
||||
challenge: challenge.challenge,
|
||||
number,
|
||||
salt: challenge.salt,
|
||||
signature: challenge.signature,
|
||||
},
|
||||
hmacKey
|
||||
);
|
||||
expect(ok).toEqual(true);
|
||||
});
|
||||
|
||||
it('should return false if number is incorrect', async () => {
|
||||
const challenge = await createChallenge({
|
||||
number: 100,
|
||||
hmacKey,
|
||||
});
|
||||
const ok = await verifySolution(
|
||||
{
|
||||
algorithm: challenge.algorithm,
|
||||
challenge: challenge.challenge,
|
||||
number: 444,
|
||||
salt: challenge.salt,
|
||||
signature: challenge.signature,
|
||||
},
|
||||
hmacKey
|
||||
);
|
||||
expect(ok).toEqual(false);
|
||||
});
|
||||
|
||||
it('should return false if salt is incorrect', async () => {
|
||||
const number = 100;
|
||||
const challenge = await createChallenge({
|
||||
number,
|
||||
hmacKey,
|
||||
});
|
||||
const ok = await verifySolution(
|
||||
{
|
||||
algorithm: challenge.algorithm,
|
||||
challenge: challenge.challenge,
|
||||
number,
|
||||
salt: 'wrong salt',
|
||||
signature: challenge.signature,
|
||||
},
|
||||
hmacKey
|
||||
);
|
||||
expect(ok).toEqual(false);
|
||||
});
|
||||
|
||||
it('should return false if signature is incorrect', async () => {
|
||||
const number = 100;
|
||||
const challenge = await createChallenge({
|
||||
number,
|
||||
hmacKey,
|
||||
});
|
||||
const ok = await verifySolution(
|
||||
{
|
||||
algorithm: challenge.algorithm,
|
||||
challenge: challenge.challenge,
|
||||
number,
|
||||
salt: challenge.salt,
|
||||
signature: 'wrong signature',
|
||||
},
|
||||
hmacKey
|
||||
);
|
||||
expect(ok).toEqual(false);
|
||||
});
|
||||
|
||||
it('should return false if hmacKey is incorrect', async () => {
|
||||
const number = 100;
|
||||
const challenge = await createChallenge({
|
||||
number,
|
||||
hmacKey,
|
||||
});
|
||||
const ok = await verifySolution(
|
||||
{
|
||||
algorithm: challenge.algorithm,
|
||||
challenge: challenge.challenge,
|
||||
number,
|
||||
salt: challenge.salt,
|
||||
signature: challenge.signature,
|
||||
},
|
||||
'wrong key'
|
||||
);
|
||||
expect(ok).toEqual(false);
|
||||
});
|
||||
|
||||
it('should return false if algorithm is incorrect', async () => {
|
||||
const number = 100;
|
||||
const challenge = await createChallenge({
|
||||
number,
|
||||
hmacKey,
|
||||
});
|
||||
const ok = await verifySolution(
|
||||
{
|
||||
algorithm: 'SHA-1',
|
||||
challenge: challenge.challenge,
|
||||
number,
|
||||
salt: challenge.salt,
|
||||
signature: challenge.signature,
|
||||
},
|
||||
hmacKey
|
||||
);
|
||||
expect(ok).toEqual(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
37
tests/deno.ts
Normal file
37
tests/deno.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { assertEquals } from 'https://deno.land/std@0.213.0/assert/mod.ts';
|
||||
import { createChallenge, verifySolution } from '../deno_dist/index.ts';
|
||||
|
||||
const hmacKey = 'test';
|
||||
|
||||
Deno.test('createChallenge()', async (t) => {
|
||||
await t.step('should create a new challenge', async () => {
|
||||
const challenge = await createChallenge({
|
||||
hmacKey,
|
||||
});
|
||||
assertEquals(challenge.algorithm, 'SHA-256');
|
||||
assertEquals(challenge.salt.length, 24);
|
||||
assertEquals(challenge.challenge.length, 64);
|
||||
assertEquals(challenge.signature.length, 64);
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test('verifySolution()', async (t) => {
|
||||
await t.step('should verify solution', async () => {
|
||||
const number = 100;
|
||||
const challenge = await createChallenge({
|
||||
hmacKey,
|
||||
number,
|
||||
});
|
||||
const ok = await verifySolution(
|
||||
{
|
||||
algorithm: challenge.algorithm,
|
||||
challenge: challenge.challenge,
|
||||
number,
|
||||
salt: challenge.salt,
|
||||
signature: challenge.signature,
|
||||
},
|
||||
hmacKey
|
||||
);
|
||||
assertEquals(ok, true);
|
||||
});
|
||||
});
|
||||
84
tests/helpers.test.ts
Normal file
84
tests/helpers.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ab2hex, hash, hmac, randomBytes, randomInt } from '../lib/helpers.js';
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
describe('helpers', () => {
|
||||
describe('ab2hex()', () => {
|
||||
it('should return hex-encoded string', () => {
|
||||
expect(ab2hex(encoder.encode('hello world'))).toEqual(
|
||||
'68656c6c6f20776f726c64'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hash()', () => {
|
||||
it('should return SHA-1 hash', async () => {
|
||||
expect(await hash('SHA-1', 'hello world')).toEqual(
|
||||
'2aae6c35c94fcfb415dbe95f408b9ce91ee846ed'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return SHA-256 hash', async () => {
|
||||
expect(await hash('SHA-256', 'hello world')).toEqual(
|
||||
'b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return SHA-512 hash', async () => {
|
||||
expect(await hash('SHA-512', 'hello world')).toEqual(
|
||||
'309ecc489c12d6eb4cc40f50c902f2b4d0ed77ee511a7c7a9bcd3ca86d4cd86f989dd35bc5ff499670da34255b45b0cfd830e81f605dcf7dc5542e93ae9cd76f'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hmac()', () => {
|
||||
it('should return HMAC-1', async () => {
|
||||
expect(await hmac('SHA-1', 'hello world', 'test')).toEqual(
|
||||
'5a09e304f3c60d633ff16735ec931e1116ff21d1'
|
||||
);
|
||||
});
|
||||
it('should return HMAC-256', async () => {
|
||||
expect(await hmac('SHA-256', 'hello world', 'test')).toEqual(
|
||||
'd1596e0d4280f2bd2d311ce0819f23bde0dc834d8254b92924088de94c38d922'
|
||||
);
|
||||
});
|
||||
it('should return HMAC-512', async () => {
|
||||
expect(await hmac('SHA-512', 'hello world', 'test')).toEqual(
|
||||
'2536d175df94a4638110701d8a0e2cbe56e35f2dcfd167819148cd0f2c8780cb3d3df52b4aea8f929004dd07235ae802f4b5d160a2b8b82e8c2f066289de85a3'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('randomBytes()', () => {
|
||||
it('should return an Uint8Array with random values', () => {
|
||||
const ab = randomBytes(10);
|
||||
expect(ab.length).toEqual(10);
|
||||
expect([...ab].some((b) => b !== 0)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('randomInt()', () => {
|
||||
it('should return a random integers', () => {
|
||||
const max = 1000;
|
||||
const numbers = [
|
||||
randomInt(max),
|
||||
randomInt(max),
|
||||
randomInt(max),
|
||||
randomInt(max),
|
||||
randomInt(max),
|
||||
randomInt(max),
|
||||
];
|
||||
expect(numbers.some((n) => n !== numbers[0]));
|
||||
});
|
||||
|
||||
it('should return a random integer within the limit', () => {
|
||||
const max = 4;
|
||||
for (let i = 1; i < 1000; i++) {
|
||||
const num = randomInt(max);
|
||||
expect(num).toBeGreaterThanOrEqual(0);
|
||||
expect(num).toBeLessThanOrEqual(max);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
4
tsconfig.build.json
Normal file
4
tsconfig.build.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["lib/**/*"]
|
||||
}
|
||||
22
tsconfig.json
Normal file
22
tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM"],
|
||||
"module": "ESNext",
|
||||
"target": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"moduleDetection": "force",
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"types": [
|
||||
"node",
|
||||
"vitest/importMeta",
|
||||
],
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "."
|
||||
},
|
||||
"exclude": ["node_modules"],
|
||||
"include": ["lib/**/*"],
|
||||
}
|
||||
Reference in New Issue
Block a user