├── .github └── workflows │ ├── ci.yml │ ├── pre-commit.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── altcha ├── __init__.py ├── altcha.py └── py.typed ├── setup.py └── tests └── test_altcha.py /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Build and Publish 2 | 3 | on: push 4 | 5 | jobs: 6 | build: 7 | name: Build distribution 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v4 12 | - name: Set up Python 13 | uses: actions/setup-python@v5 14 | with: 15 | python-version: "3.x" 16 | - name: Install pypa/build 17 | run: >- 18 | python3 -m 19 | pip install 20 | build 21 | --user 22 | - name: Build a binary wheel and a source tarball 23 | run: python3 -m build 24 | - name: Store the distribution packages 25 | uses: actions/upload-artifact@v4 26 | with: 27 | name: python-package-distributions 28 | path: dist/ 29 | 30 | publish-to-pypi: 31 | name: Publish Python 32 | if: startsWith(github.ref, 'refs/tags/') 33 | needs: 34 | - build 35 | runs-on: ubuntu-latest 36 | environment: 37 | name: pypi 38 | url: https://pypi.org/p/altcha 39 | permissions: 40 | id-token: write # IMPORTANT: mandatory for trusted publishing 41 | steps: 42 | - name: Download all the dists 43 | uses: actions/download-artifact@v4 44 | with: 45 | name: python-package-distributions 46 | path: dist/ 47 | - name: Publish to PyPI 48 | uses: pypa/gh-action-pypi-publish@release/v1 -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yml: -------------------------------------------------------------------------------- 1 | name: pre-commit check 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | pre-commit: 12 | runs-on: ubuntu-24.04 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Setup Python 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: '3.13' 20 | - uses: astral-sh/setup-uv@v4 21 | - name: pre-commit 22 | run: uvx pre-commit run --all 23 | env: 24 | RUFF_OUTPUT_FORMAT: github 25 | - name: show diff 26 | run: git diff 27 | if: always() 28 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | pytest: 12 | runs-on: ubuntu-24.04 13 | strategy: 14 | matrix: 15 | python-version: 16 | - '3.9' 17 | - '3.10' 18 | - '3.11' 19 | - '3.12' 20 | - '3.13' 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | - name: Setup Python 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | - uses: astral-sh/setup-uv@v4 29 | - run: uv pip install --system . pytest 30 | - run: pytest tests 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # Installer logs 29 | pip-log.txt 30 | pip-delete-this-directory.txt 31 | 32 | # Virtual environments 33 | venv/ 34 | ENV/ 35 | env/ 36 | env.bak/ 37 | venv.bak/ 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Sphinx documentation 55 | docs/_build/ 56 | 57 | # Jupyter Notebook 58 | .ipynb_checkpoints 59 | 60 | # PyInstaller 61 | *.manifest 62 | *.spec 63 | 64 | # pyenv 65 | .python-version 66 | 67 | # pipenv 68 | Pipfile.lock 69 | 70 | # dotenv 71 | .env 72 | .env.* 73 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/astral-sh/ruff-pre-commit 5 | rev: v0.8.0 6 | hooks: 7 | - id: ruff 8 | args: [--fix, --exit-non-zero-on-fix] 9 | - id: ruff-format 10 | - repo: https://github.com/pre-commit/mirrors-mypy 11 | rev: v1.13.0 12 | hooks: 13 | - id: mypy 14 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | GitHub Issues. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to ALTCHA 2 | 3 | We appreciate your contributions! To ensure a smooth and transparent collaboration, we've outlined the various ways you can contribute to ALTCHA: 4 | 5 | - **Reporting a Bug** 6 | - **Discussing the Current State of the Code** 7 | - **Submitting a Fix** 8 | - **Proposing New Features** 9 | - **Becoming a Maintainer** 10 | 11 | ## Development with GitHub 12 | 13 | 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. 14 | 15 | ## Project Technology: Svelte 16 | 17 | ALTCHA utilizes [Svelte](https://svelte.dev) for its Web Component widget. Refer to Svelte's documentation to set up your development environment. 18 | 19 | ## Licensing Information 20 | 21 | 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. 22 | 23 | ## Reporting Bugs Using GitHub's [Issues](https://github.com/altcha-org/altcha-lib-py/issues) 24 | 25 | We track public bugs using GitHub issues. Reporting a bug is easy: simply [open a new issue](https://github.com/altcha-org/altcha-lib-py/issues). Provide detailed information for effective bug resolution. 26 | 27 | ## Writing Effective Bug Reports 28 | 29 | Good bug reports include: 30 | 31 | - A quick summary and background of the issue 32 | - Steps to reproduce the problem 33 | - Be specific! 34 | - Include sample code if possible 35 | - Expected vs. actual outcomes 36 | - Additional notes, such as your hypotheses or unsuccessful attempts to resolve the issue 37 | 38 | ## License Agreement 39 | 40 | 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. 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Daniel Regeci 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ALTCHA Python Library 2 | 3 | The ALTCHA Python Library is a lightweight, zero-dependency library designed for creating and verifying [ALTCHA](https://altcha.org) challenges, specifically tailored for Python applications. 4 | 5 | ## Compatibility 6 | 7 | This library is compatible with: 8 | 9 | - Python 3.9+ 10 | 11 | ## Example 12 | 13 | - [Demo server](https://github.com/altcha-org/altcha-starter-py) 14 | 15 | ## Installation 16 | 17 | To install the ALTCHA Python Library, use the following command: 18 | 19 | ```sh 20 | pip install altcha 21 | ``` 22 | 23 | ## Build 24 | 25 | ```sh 26 | python -m build 27 | ``` 28 | 29 | ## Tests 30 | 31 | ```sh 32 | python -m unittest discover tests 33 | ``` 34 | 35 | ## Usage 36 | 37 | Here’s a basic example of how to use the ALTCHA Python Library: 38 | 39 | ```python 40 | import datetime 41 | from altcha import ChallengeOptions, create_challenge, verify_solution 42 | 43 | def main(): 44 | hmac_key = "secret hmac key" 45 | 46 | # Create a new challenge 47 | options = ChallengeOptions( 48 | expires=datetime.datetime.now() + datetime.timedelta(hours=1), 49 | max_number=100000, # The maximum random number 50 | hmac_key=hmac_key, 51 | ) 52 | challenge = create_challenge(options) 53 | print("Challenge created:", challenge) 54 | 55 | # Example payload to verify 56 | payload = { 57 | "algorithm": challenge.algorithm, 58 | "challenge": challenge.challenge, 59 | "number": 12345, # Example number 60 | "salt": challenge.salt, 61 | "signature": challenge.signature, 62 | } 63 | 64 | # Verify the solution 65 | ok, err = verify_solution(payload, hmac_key, check_expires=True) 66 | if err: 67 | print("Error:", err) 68 | elif ok: 69 | print("Solution verified!") 70 | else: 71 | print("Invalid solution.") 72 | 73 | if __name__ == "__main__": 74 | main() 75 | ``` 76 | 77 | ## API 78 | 79 | ### `create_challenge(options)` 80 | 81 | Creates a new challenge for ALTCHA. 82 | 83 | **Parameters:** 84 | 85 | - `options (dict)`: 86 | - `algorithm (str)`: Hashing algorithm to use (`'SHA-1'`, `'SHA-256'`, `'SHA-512'`, default: `'SHA-256'`). 87 | - `max_number (int)`: Maximum number for the random number generator (default: 1,000,000). 88 | - `salt_length (int)`: Length of the random salt in bytes (default: 12). 89 | - `hmac_key (str)`: Required HMAC key. 90 | - `salt (str)`: Optional salt string. If not provided, a random salt will be generated. 91 | - `number (int)`: Optional specific number to use. If not provided, a random number will be generated. 92 | - `expires (datetime)`: Optional expiration time for the challenge. 93 | - `params (dict)`: Optional URL-encoded query parameters. 94 | 95 | **Returns:** `Challenge` 96 | 97 | ### `verify_solution(payload, hmac_key, check_expires)` 98 | 99 | Verifies an ALTCHA solution. 100 | 101 | **Parameters:** 102 | 103 | - `payload (dict)`: The solution payload to verify. 104 | - `hmac_key (str)`: The HMAC key used for verification. 105 | - `check_expires (bool)`: Indicates whether to validate the challenge's expiration. If set to True, the function checks the expires field within the salt (if present) to ensure the challenge has not expired. 106 | (Note: To use this feature, the expires parameter must be included when creating the challenge.) 107 | 108 | **Returns:** `(bool, str or None)` 109 | 110 | ### `extract_params(payload)` 111 | 112 | Extracts URL parameters from the payload's salt. 113 | 114 | **Parameters:** 115 | 116 | - `payload (dict)`: The payload containing the salt. 117 | 118 | **Returns:** `dict` 119 | 120 | ### `verify_fields_hash(form_data, fields, fields_hash, algorithm)` 121 | 122 | Verifies the hash of form fields. 123 | 124 | **Parameters:** 125 | 126 | - `form_data (dict)`: The form data to hash. 127 | - `fields (list)`: The fields to include in the hash. 128 | - `fields_hash (str)`: The expected hash value. 129 | - `algorithm (str)`: Hashing algorithm (`'SHA-1'`, `'SHA-256'`, `'SHA-512'`). 130 | 131 | **Returns:** `bool` 132 | 133 | ### `verify_server_signature(payload, hmac_key)` 134 | 135 | Verifies the server signature. 136 | 137 | **Parameters:** 138 | 139 | - `payload (dict or str)`: The payload to verify (base64 encoded JSON string or dictionary). 140 | - `hmac_key (str)`: The HMAC key used for verification. 141 | 142 | **Returns:** `(bool, ServerSignatureVerificationData, str or None)` 143 | 144 | ### `solve_challenge(challenge, salt, algorithm, max_number, start, stop_chan)` 145 | 146 | Finds a solution to the given challenge. 147 | 148 | **Parameters:** 149 | 150 | - `challenge (str)`: The challenge hash. 151 | - `salt (str)`: The challenge salt. 152 | - `algorithm (str)`: Hashing algorithm (`'SHA-1'`, `'SHA-256'`, `'SHA-512'`). 153 | - `max_number (int)`: Maximum number to iterate to. 154 | - `start (int)`: Starting number. 155 | 156 | **Returns:** `Solution or None` 157 | 158 | ## License 159 | 160 | MIT -------------------------------------------------------------------------------- /altcha/__init__.py: -------------------------------------------------------------------------------- 1 | from .altcha import ChallengeOptions as ChallengeOptions 2 | from .altcha import Challenge as Challenge 3 | from .altcha import Payload as Payload 4 | from .altcha import ServerSignaturePayload as ServerSignaturePayload 5 | from .altcha import ServerSignatureVerificationData as ServerSignatureVerificationData 6 | from .altcha import Solution as Solution 7 | from .altcha import create_challenge as create_challenge 8 | from .altcha import extract_params as extract_params 9 | from .altcha import verify_fields_hash as verify_fields_hash 10 | from .altcha import verify_solution as verify_solution 11 | from .altcha import verify_server_signature as verify_server_signature 12 | from .altcha import solve_challenge as solve_challenge 13 | -------------------------------------------------------------------------------- /altcha/altcha.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import hashlib 4 | import hmac 5 | import base64 6 | import json 7 | import secrets 8 | import time 9 | import urllib.parse 10 | from typing import Literal, TypedDict, cast, overload 11 | import datetime 12 | 13 | # Define algorithms 14 | SHA1: Literal["SHA-1"] = "SHA-1" 15 | SHA256: Literal["SHA-256"] = "SHA-256" 16 | SHA512: Literal["SHA-512"] = "SHA-512" 17 | 18 | AlgoType = Literal["SHA-1", "SHA-256", "SHA-512"] 19 | 20 | 21 | class PayloadType(TypedDict, total=False): 22 | algorithm: AlgoType 23 | challenge: str 24 | number: int 25 | salt: str 26 | signature: str 27 | verificationData: str 28 | verified: bool 29 | 30 | 31 | DEFAULT_MAX_NUMBER: int = int(1e6) # Default maximum number for challenge 32 | DEFAULT_SALT_LENGTH: int = 12 # Default length of salt in bytes 33 | DEFAULT_ALGORITHM: AlgoType = SHA256 # Default hashing algorithm 34 | 35 | 36 | class ChallengeOptions: 37 | """ 38 | Represents options for creating a challenge. 39 | 40 | Attributes: 41 | algorithm (str): Hashing algorithm to use (e.g., 'SHA-1', 'SHA-256', 'SHA-512'). 42 | max_number (int): Maximum number to use for the challenge. 43 | salt_length (int): Length of the salt in bytes. 44 | hmac_key (str): HMAC key for generating the signature. 45 | salt (str): Optional salt value. If not provided, a random salt is generated. 46 | number (int): Optional number for the challenge. If not provided, a random number is used. 47 | expires (datetime): Optional expiration time for the challenge. 48 | params (dict): Optional additional parameters to include in the challenge. 49 | """ 50 | 51 | def __init__( 52 | self, 53 | algorithm: AlgoType = DEFAULT_ALGORITHM, 54 | max_number: int = DEFAULT_MAX_NUMBER, 55 | salt_length: int = DEFAULT_SALT_LENGTH, 56 | hmac_key: str = "", 57 | salt: str = "", 58 | number: int | None = None, 59 | expires: datetime.datetime | None = None, 60 | params: dict[str, str] | None = None, 61 | ): 62 | self.algorithm = algorithm 63 | self.max_number = max_number 64 | self.salt_length = salt_length 65 | self.hmac_key = hmac_key 66 | self.salt = salt 67 | self.number = number 68 | self.expires = expires 69 | self.params = params if params else {} 70 | 71 | 72 | class Challenge: 73 | """ 74 | Represents a generated challenge. 75 | 76 | Attributes: 77 | algorithm (str): Hashing algorithm used. 78 | challenge (str): Challenge string. 79 | max_number (int): Maximum number used for the challenge. 80 | salt (str): Salt used for generating the challenge. 81 | signature (str): HMAC signature for the challenge. 82 | """ 83 | 84 | def __init__( 85 | self, 86 | algorithm: AlgoType, 87 | challenge: str, 88 | max_number: int, 89 | salt: str, 90 | signature: str, 91 | ): 92 | self.algorithm = algorithm 93 | self.challenge = challenge 94 | self.max_number = max_number 95 | self.salt = salt 96 | self.signature = signature 97 | 98 | def to_dict(self) -> dict: 99 | """Convert the Challenge to a dictionary.""" 100 | return { 101 | "algorithm": self.algorithm, 102 | "challenge": self.challenge, 103 | "maxNumber": self.max_number, 104 | "salt": self.salt, 105 | "signature": self.signature, 106 | } 107 | 108 | 109 | class Payload: 110 | """ 111 | Represents the payload of a challenge solution. 112 | 113 | Attributes: 114 | algorithm (str): Hashing algorithm used. 115 | challenge (str): Challenge string. 116 | number (int): Number used in the solution. 117 | salt (str): Salt used in the solution. 118 | signature (str): HMAC signature of the solution. 119 | """ 120 | 121 | def __init__( 122 | self, 123 | algorithm: AlgoType, 124 | challenge: str, 125 | number: int, 126 | salt: str, 127 | signature: str, 128 | ): 129 | self.algorithm = algorithm 130 | self.challenge = challenge 131 | self.number = number 132 | self.salt = salt 133 | self.signature = signature 134 | 135 | def to_dict(self) -> PayloadType: 136 | """Convert the Payload to a dictionary.""" 137 | return { 138 | "algorithm": self.algorithm, 139 | "challenge": self.challenge, 140 | "number": self.number, 141 | "salt": self.salt, 142 | "signature": self.signature, 143 | } 144 | 145 | def to_base64(self) -> str: 146 | """Convert the Payload to a base64 encoded JSON string.""" 147 | return base64.b64encode(json.dumps(self.to_dict()).encode()).decode() 148 | 149 | 150 | class ServerSignaturePayload: 151 | """ 152 | Represents the payload for server signature verification. 153 | 154 | Attributes: 155 | algorithm (str): Hashing algorithm used. 156 | apiKey (str): API Key used for signature. 157 | id (str): Unique signature id. 158 | verificationData (str): Data used for verification. 159 | signature (str): HMAC signature of the verification data. 160 | verified (bool): Whether the signature was verified. 161 | """ 162 | 163 | def __init__( 164 | self, 165 | algorithm: AlgoType, 166 | apiKey: str, 167 | id: str, 168 | verificationData: str, 169 | signature: str, 170 | verified: bool, 171 | ): 172 | self.algorithm = algorithm 173 | self.apiKey = apiKey 174 | self.id = id 175 | self.verificationData = verificationData 176 | self.signature = signature 177 | self.verified = verified 178 | 179 | def to_dict(self) -> dict: 180 | """Convert the ServerSignaturePayload to a dictionary.""" 181 | return { 182 | "algorithm": self.algorithm, 183 | "apiKey": self.apiKey, 184 | "id": self.id, 185 | "verificationData": self.verificationData, 186 | "signature": self.signature, 187 | "verified": self.verified, 188 | } 189 | 190 | def to_base64(self) -> str: 191 | """Convert the ServerSignaturePayload to a base64 encoded JSON string.""" 192 | return base64.b64encode(json.dumps(self.to_dict()).encode()).decode() 193 | 194 | 195 | class ServerSignatureVerificationData: 196 | """ 197 | Represents verification data for server signatures with support for custom string:string attributes. 198 | 199 | Attributes: 200 | classification (str): The classification of the verification 201 | country (str): [DEPRECATED] Use "location.countryCode" instead with Sentinel. 202 | detectedLanguage (str): [DEPRECATED] Use "text.language" instead with Sentinel. 203 | email (str): The associated email 204 | expire (int): Expiration timestamp 205 | fields (list[str]): List of fields 206 | fieldsHash (str): Hash of the fields 207 | ipAddress (str): The IP address 208 | reasons (list[str]): List of reasons 209 | score (float): Verification score 210 | time (int): Timestamp 211 | verified (bool): Verification status 212 | """ 213 | 214 | def __init__( 215 | self, 216 | classification: str = "", 217 | country: str = "", 218 | detected_language: str = "", 219 | email: str = "", 220 | expire: int = 0, 221 | fields: list[str] | None = None, 222 | fields_hash: str = "", 223 | ip_address: str = "", 224 | reasons: list[str] | None = None, 225 | score: float = 0.0, 226 | time: int = 0, 227 | verified: bool = False, 228 | **custom: str, 229 | ): 230 | self.classification = classification 231 | self.country = country 232 | self.detectedLanguage = detected_language 233 | self.email = email 234 | self.expire = expire 235 | self.fields = fields if fields else [] 236 | self.fieldsHash = fields_hash 237 | self.ipAddress = ip_address 238 | self.reasons = reasons if reasons else [] 239 | self.score = score 240 | self.time = time 241 | self.verified = verified 242 | 243 | # Store any extra custom attributes (must be str:str) 244 | for key, value in custom.items(): 245 | if not isinstance(value, str): 246 | raise TypeError(f"Custom attribute '{key}' must be of type str") 247 | setattr(self, key, value) 248 | 249 | def to_dict(self) -> dict: 250 | """ 251 | Converts the ServerSignatureVerificationData object to a dictionary. 252 | 253 | Returns: 254 | A dictionary containing all the standard and custom attributes. 255 | """ 256 | # Standard attributes 257 | data = { 258 | "classification": self.classification, 259 | "country": self.country, 260 | "detectedLanguage": self.detectedLanguage, 261 | "email": self.email, 262 | "expire": self.expire, 263 | "fields": self.fields.copy(), 264 | "fieldsHash": self.fieldsHash, 265 | "ipAddress": self.ipAddress, 266 | "reasons": self.reasons.copy(), 267 | "score": self.score, 268 | "time": self.time, 269 | "verified": self.verified, 270 | } 271 | 272 | # Add custom attributes 273 | for key, value in self.__dict__.items(): 274 | if key not in data and isinstance(value, str): 275 | data[key] = value 276 | 277 | return data 278 | 279 | 280 | class Solution: 281 | """ 282 | Represents a solution to a challenge. 283 | 284 | Attributes: 285 | number (int): Number that solved the challenge. 286 | took (float): Time taken to solve the challenge, in seconds. 287 | """ 288 | 289 | def __init__(self, number: int, took: float): 290 | self.number = number 291 | self.took = took 292 | 293 | 294 | def hash_hex(algorithm: AlgoType, data: bytes) -> str: 295 | """ 296 | Computes the hexadecimal digest of the given data using the specified hashing algorithm. 297 | 298 | Args: 299 | algorithm (str): Hashing algorithm to use (e.g., 'SHA-1', 'SHA-256', 'SHA-512'). 300 | data (bytes): Data to hash. 301 | 302 | Returns: 303 | str: Hexadecimal digest of the data. 304 | """ 305 | hash_obj = hash_algorithm(algorithm) 306 | hash_obj.update(data) 307 | return hash_obj.hexdigest() 308 | 309 | 310 | def hash_algorithm(algorithm: AlgoType) -> hashlib._Hash: 311 | """ 312 | Returns a hash object for the specified hashing algorithm. 313 | 314 | Args: 315 | algorithm (str): Hashing algorithm to use (e.g., 'SHA-1', 'SHA-256', 'SHA-512'). 316 | 317 | Returns: 318 | hashlib.Hash: Hash object for the specified algorithm. 319 | 320 | Raises: 321 | ValueError: If the algorithm is unsupported. 322 | """ 323 | if algorithm == SHA1: 324 | return hashlib.sha1() 325 | elif algorithm == SHA256: 326 | return hashlib.sha256() 327 | elif algorithm == SHA512: 328 | return hashlib.sha512() 329 | else: 330 | raise ValueError(f"Unsupported algorithm: {algorithm}") 331 | 332 | 333 | def hmac_hex(algorithm: AlgoType, data: bytes, key: str) -> str: 334 | """ 335 | Computes the HMAC hexadecimal digest of the given data using the specified algorithm and key. 336 | 337 | Args: 338 | algorithm (str): Hashing algorithm to use (e.g., 'SHA-1', 'SHA-256', 'SHA-512'). 339 | data (bytes): Data to HMAC. 340 | key (str): Key for the HMAC. 341 | 342 | Returns: 343 | str: Hexadecimal HMAC digest of the data. 344 | """ 345 | hmac_obj = hmac.new( 346 | key.encode(), data, getattr(hashlib, algorithm.replace("-", "").lower()) 347 | ) 348 | return hmac_obj.hexdigest() 349 | 350 | 351 | @overload 352 | def create_challenge(options: ChallengeOptions) -> Challenge: ... 353 | @overload 354 | def create_challenge( 355 | *, 356 | algorithm: AlgoType, 357 | max_number: int, 358 | salt_length: int, 359 | hmac_key: str, 360 | salt: str, 361 | number: int | None, 362 | expires: datetime.datetime | None, 363 | params: dict[str, str] | None, 364 | ) -> Challenge: ... 365 | 366 | 367 | def create_challenge( 368 | options: ChallengeOptions | None = None, 369 | *, 370 | algorithm: AlgoType = DEFAULT_ALGORITHM, 371 | max_number: int = DEFAULT_MAX_NUMBER, 372 | salt_length: int = DEFAULT_SALT_LENGTH, 373 | hmac_key: str = "", 374 | salt: str = "", 375 | number: int | None = None, 376 | expires: datetime.datetime | None = None, 377 | params: dict[str, str] | None = None, 378 | ) -> Challenge: 379 | """ 380 | Creates a challenge based on the provided options. 381 | 382 | Args: 383 | options (ChallengeOptions): Options for creating the challenge. 384 | or individual parameters: 385 | algorithm (str): Hashing algorithm to use. 386 | max_number (int): Maximum number to use. 387 | salt_length (int): Length of the salt in bytes. 388 | hmac_key (str): HMAC key for generating the signature. 389 | salt (str): Optional salt value. 390 | number (int): Optional number for the challenge. 391 | expires (datetime): Optional expiration time. 392 | params (dict): Optional additional parameters. 393 | 394 | Returns: 395 | Challenge: The generated challenge. 396 | """ 397 | if options is None: 398 | options = ChallengeOptions( 399 | algorithm=algorithm, 400 | max_number=max_number, 401 | salt_length=salt_length, 402 | hmac_key=hmac_key, 403 | salt=salt, 404 | number=number, 405 | expires=expires, 406 | params=params, 407 | ) 408 | 409 | algorithm = options.algorithm or DEFAULT_ALGORITHM 410 | max_number = options.max_number or DEFAULT_MAX_NUMBER 411 | salt_length = options.salt_length or DEFAULT_SALT_LENGTH 412 | 413 | salt = ( 414 | options.salt 415 | or base64.b16encode(secrets.token_bytes(salt_length)).decode("utf-8").lower() 416 | ) 417 | number = ( 418 | options.number 419 | if options.number is not None 420 | else secrets.randbelow(max_number + 1) 421 | ) 422 | 423 | salt_params = {} 424 | if "?" in salt: 425 | salt, salt_query = salt.split("?", 2) 426 | salt_params = dict(urllib.parse.parse_qsl(salt_query)) 427 | 428 | if options.expires: 429 | salt_params["expires"] = str(int(time.mktime(options.expires.timetuple()))) 430 | 431 | if options.params: 432 | salt_params.update(options.params) 433 | 434 | if salt_params: 435 | salt += "?" + urllib.parse.urlencode(salt_params) 436 | 437 | challenge = hash_hex(algorithm, (salt + str(number)).encode()) 438 | signature = hmac_hex(algorithm, challenge.encode(), options.hmac_key) 439 | 440 | return Challenge(algorithm, challenge, max_number, salt, signature) 441 | 442 | 443 | @overload 444 | def verify_solution( 445 | payload: Payload, 446 | hmac_key: str, 447 | check_expires: bool = True, 448 | ) -> tuple[bool, str | None]: ... 449 | @overload 450 | def verify_solution( 451 | payload: str, 452 | hmac_key: str, 453 | check_expires: bool = True, 454 | ) -> tuple[bool, str | None]: ... 455 | @overload 456 | def verify_solution( 457 | payload: PayloadType, 458 | hmac_key: str, 459 | check_expires: bool = True, 460 | ) -> tuple[bool, str | None]: ... 461 | 462 | 463 | def verify_solution( 464 | payload: str | Payload | PayloadType, 465 | hmac_key: str, 466 | check_expires: bool = True, 467 | ) -> tuple[bool, str | None]: 468 | """ 469 | Verifies a challenge solution against the expected challenge. 470 | 471 | Args: 472 | payload (str | Payload | PayloadType): The solution payload to verify. 473 | hmac_key (str): HMAC key for verifying the solution. 474 | check_expires (bool): Whether to check the expiration time. 475 | 476 | Returns: 477 | tuple: (bool: verification success, str | None: error message if any) 478 | """ 479 | payload_dict: PayloadType 480 | if isinstance(payload, Payload): 481 | payload_dict = payload.to_dict() 482 | elif isinstance(payload, str): 483 | try: 484 | payload_dict = cast( 485 | PayloadType, json.loads(base64.b64decode(payload).decode()) 486 | ) 487 | except (ValueError, TypeError): 488 | return False, "Invalid altcha payload" 489 | else: 490 | payload_dict = payload 491 | 492 | required_fields = ["algorithm", "challenge", "number", "salt", "signature"] 493 | for field in required_fields: 494 | if field not in payload_dict: 495 | return False, f"Missing required field: {field}" 496 | 497 | if payload_dict["algorithm"] not in ["SHA-1", "SHA-256", "SHA-512"]: 498 | return False, "Invalid algorithm" 499 | 500 | expires = extract_params(payload_dict).get("expires") 501 | try: 502 | if check_expires and expires and int(expires[0]) < time.time(): 503 | return False, "Altcha payload expired" 504 | except ValueError: # Guard against malformed expires 505 | return False, "Altcha payload expired" 506 | 507 | options = ChallengeOptions( 508 | algorithm=payload_dict["algorithm"], 509 | hmac_key=hmac_key, 510 | number=payload_dict["number"], 511 | salt=payload_dict["salt"], 512 | ) 513 | expected_challenge = create_challenge(options) 514 | 515 | return ( 516 | expected_challenge.challenge == payload_dict["challenge"] 517 | and expected_challenge.signature == payload_dict["signature"] 518 | ), None 519 | 520 | 521 | def extract_params(payload: PayloadType) -> dict[str, list[str]]: 522 | """ 523 | Extracts query parameters from the salt string in the payload. 524 | 525 | Args: 526 | payload (dict): Payload containing the salt. 527 | 528 | Returns: 529 | dict: Dictionary of query parameters extracted from the salt. 530 | """ 531 | split_salt = payload["salt"].split("?") 532 | if len(split_salt) > 1: 533 | return urllib.parse.parse_qs(split_salt[1]) 534 | return {} 535 | 536 | 537 | def verify_fields_hash( 538 | form_data: dict[str, str], fields: list[str], fields_hash: str, algorithm: AlgoType 539 | ) -> bool: 540 | """ 541 | Verifies that the hash of specific form fields matches the expected hash. 542 | 543 | Args: 544 | form_data (dict): Form data containing the fields to hash. 545 | fields (list): List of field names to include in the hash. 546 | fields_hash (str): Expected hash of the fields. 547 | algorithm (str): Hashing algorithm to use (e.g., 'SHA-1', 'SHA-256', 'SHA-512'). 548 | 549 | Returns: 550 | bool: True if the computed hash matches the expected hash, False otherwise. 551 | """ 552 | lines = [form_data.get(field, "") for field in fields] 553 | joined_data = "\n".join(lines) 554 | computed_hash = hash_hex(algorithm, joined_data.encode()) 555 | return computed_hash == fields_hash 556 | 557 | 558 | @overload 559 | def verify_server_signature( 560 | payload: str, 561 | hmac_key: str, 562 | ) -> tuple[bool, ServerSignatureVerificationData | None, str | None]: ... 563 | @overload 564 | def verify_server_signature( 565 | payload: ServerSignaturePayload, 566 | hmac_key: str, 567 | ) -> tuple[bool, ServerSignatureVerificationData | None, str | None]: ... 568 | @overload 569 | def verify_server_signature( 570 | payload: PayloadType, 571 | hmac_key: str, 572 | ) -> tuple[bool, ServerSignatureVerificationData | None, str | None]: ... 573 | 574 | 575 | def verify_server_signature( 576 | payload: str | ServerSignaturePayload | PayloadType, 577 | hmac_key: str, 578 | ) -> tuple[bool, ServerSignatureVerificationData | None, str | None]: 579 | """ 580 | Verifies the server signature in the payload. 581 | 582 | Args: 583 | payload: The payload containing the server signature. 584 | hmac_key: HMAC key for verifying the signature. 585 | 586 | Returns: 587 | tuple: (bool: verification success, 588 | ServerSignatureVerificationData | None: verification data if successful, 589 | str | None: error message if any) 590 | """ 591 | payload_dict: PayloadType 592 | if isinstance(payload, ServerSignaturePayload): 593 | payload_dict = cast(PayloadType, payload.to_dict()) 594 | elif isinstance(payload, str): 595 | try: 596 | payload_dict = cast( 597 | PayloadType, json.loads(base64.b64decode(payload).decode()) 598 | ) 599 | except (ValueError, TypeError): 600 | return False, None, "Invalid altcha payload" 601 | else: 602 | payload_dict = payload 603 | 604 | required_fields = ["algorithm", "verificationData", "signature", "verified"] 605 | for field in required_fields: 606 | if field not in payload_dict: 607 | return False, None, "Invalid altcha payload" 608 | 609 | algorithm = payload_dict["algorithm"] 610 | verification_data = payload_dict["verificationData"] 611 | signature = payload_dict["signature"] 612 | verified = payload_dict["verified"] 613 | 614 | if algorithm not in ["SHA-1", "SHA-256", "SHA-512"]: 615 | return False, None, "Invalid algorithm" 616 | 617 | hash_obj = hash_algorithm(algorithm) 618 | hash_obj.update(verification_data.encode()) 619 | expected_signature = hmac_hex(algorithm, hash_obj.digest(), hmac_key) 620 | now = int(time.time()) 621 | params = urllib.parse.parse_qs(verification_data) 622 | expire = int(params.get("expire", [0])[0]) 623 | if expire <= now: 624 | return False, None, "Altcha payload expired" 625 | 626 | is_valid = (signature == expected_signature) and verified 627 | known_keys = { 628 | "classification", 629 | "country", 630 | "detectedLanguage", 631 | "email", 632 | "expire", 633 | "fields", 634 | "fieldsHash", 635 | "reasons", 636 | "score", 637 | "time", 638 | "verified", 639 | } 640 | 641 | data = ServerSignatureVerificationData( 642 | classification=params.get("classification", [""])[0], 643 | country=params.get("country", [""])[0], 644 | detected_language=params.get("detectedLanguage", [""])[0], 645 | email=params.get("email", [""])[0], 646 | expire=expire, 647 | fields=params.get("fields", [""])[0].split(","), 648 | fields_hash=params.get("fieldsHash", [""])[0], 649 | reasons=params.get("reasons", [""])[0].split(","), 650 | score=float(params.get("score", ["0"])[0]), 651 | time=int(params.get("time", ["0"])[0]), 652 | verified=verified, 653 | ) 654 | 655 | for key, value in params.items(): 656 | if key not in known_keys: 657 | setattr(data, key, value[0]) 658 | 659 | return is_valid, data if is_valid else None, None 660 | 661 | 662 | @overload 663 | def solve_challenge( 664 | challenge: Challenge, 665 | salt: str = "", 666 | algorithm: AlgoType = "SHA-256", 667 | max_number: int = 1000000, 668 | start: int = 0, 669 | ) -> Solution | None: ... 670 | @overload 671 | def solve_challenge( 672 | challenge: str, 673 | salt: str, 674 | algorithm: AlgoType, 675 | max_number: int, 676 | start: int = 0, 677 | ) -> Solution | None: ... 678 | 679 | 680 | def solve_challenge( 681 | challenge: Challenge | str, 682 | salt: str = "", 683 | algorithm: AlgoType = "SHA-256", 684 | max_number: int = 1000000, 685 | start: int = 0, 686 | ) -> Solution | None: 687 | """ 688 | Attempts to solve a challenge by finding a number that matches the challenge hash. 689 | Args: 690 | challenge: Either a Challenge object or the challenge string. 691 | salt: Salt used in the challenge (only needed if challenge is a string). 692 | algorithm: Hashing algorithm (only needed if challenge is a string). 693 | max_number: Maximum number to try (only needed if challenge is a string). 694 | start: Starting number to try. 695 | Returns: 696 | Solution: If the challenge is solved. 697 | None: If no solution is found within the range. 698 | """ 699 | if isinstance(challenge, Challenge): 700 | salt = challenge.salt 701 | algorithm = challenge.algorithm 702 | max_number = challenge.max_number 703 | challenge_str = challenge.challenge 704 | else: 705 | if not salt: 706 | raise ValueError( 707 | "Missing required salt parameter when challenge is a string" 708 | ) 709 | if not algorithm: 710 | algorithm = "SHA-256" 711 | if max_number <= 0: 712 | max_number = 1000000 713 | challenge_str = challenge 714 | 715 | if start < 0: 716 | start = 0 717 | 718 | start_time = time.time() 719 | for n in range(start, max_number + 1): 720 | hash_hex_value = hash_hex(algorithm, (salt + str(n)).encode()) 721 | if hash_hex_value == challenge_str: 722 | took = time.time() - start_time 723 | return Solution(n, took) 724 | 725 | return None 726 | -------------------------------------------------------------------------------- /altcha/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/altcha-org/altcha-lib-py/5e51ad323b151cd25c4adef1a6c149e44efdf545/altcha/py.typed -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name="altcha", 5 | version="0.2.0", 6 | description="A library for creating and verifying challenges for ALTCHA.", 7 | long_description=open("README.md").read(), 8 | long_description_content_type="text/markdown", 9 | author="Daniel Regeci", 10 | author_email="536331+ovx@users.noreply.github.com", 11 | url="https://github.com/altcha-org/altcha-lib-py", 12 | packages=find_packages(), 13 | classifiers=[ 14 | "Programming Language :: Python :: 3", 15 | "License :: OSI Approved :: MIT License", 16 | "Operating System :: OS Independent", 17 | ], 18 | python_requires=">=3.9", 19 | install_requires=[ 20 | # Add any dependencies here 21 | ], 22 | ) 23 | -------------------------------------------------------------------------------- /tests/test_altcha.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import hashlib 3 | import hmac 4 | import time 5 | import unittest 6 | import base64 7 | import json 8 | from altcha.altcha import ( 9 | ChallengeOptions, 10 | Payload, 11 | create_challenge, 12 | hash_algorithm, 13 | hash_hex, 14 | hmac_hex, 15 | solve_challenge, 16 | verify_server_signature, 17 | verify_solution, 18 | ) 19 | 20 | 21 | class TestALTCHA(unittest.TestCase): 22 | def setUp(self): 23 | self.hmac_key = "test-key" 24 | 25 | def test_create_challenge(self): 26 | options = ChallengeOptions( 27 | algorithm="SHA-256", 28 | max_number=1000, 29 | salt_length=16, 30 | hmac_key=self.hmac_key, 31 | salt="somesalt", 32 | number=123, 33 | ) 34 | challenge = create_challenge(options) 35 | self.assertIsNotNone(challenge) 36 | self.assertEqual(challenge.algorithm, "SHA-256") 37 | 38 | def test_verify_solution_success(self): 39 | options = ChallengeOptions( 40 | algorithm="SHA-256", 41 | max_number=1000, 42 | salt_length=16, 43 | hmac_key=self.hmac_key, 44 | salt="somesalt", 45 | number=123, 46 | ) 47 | challenge = create_challenge(options) 48 | payload = Payload( 49 | algorithm="SHA-256", 50 | challenge=challenge.challenge, 51 | number=123, 52 | salt="somesalt", 53 | signature=challenge.signature, 54 | ) 55 | payload_encoded = base64.b64encode( 56 | json.dumps(payload.__dict__).encode() 57 | ).decode() 58 | result, _ = verify_solution(payload_encoded, self.hmac_key, check_expires=False) 59 | self.assertTrue(result) 60 | 61 | def test_verify_solution_failure(self): 62 | options = ChallengeOptions( 63 | algorithm="SHA-256", 64 | max_number=1000, 65 | salt_length=16, 66 | hmac_key=self.hmac_key, 67 | salt="somesalt", 68 | number=123, 69 | ) 70 | challenge = create_challenge(options) 71 | payload = Payload( 72 | algorithm="SHA-256", 73 | challenge=challenge.challenge, 74 | number=123, 75 | salt="somesalt", 76 | signature="wrong-signature", 77 | ) 78 | payload_encoded = base64.b64encode( 79 | json.dumps(payload.__dict__).encode() 80 | ).decode() 81 | result, _ = verify_solution(payload_encoded, self.hmac_key, check_expires=False) 82 | self.assertFalse(result) 83 | 84 | # Test for number=0 85 | def test_verify_solution_zero(self): 86 | options = ChallengeOptions( 87 | algorithm="SHA-256", 88 | max_number=10, 89 | salt_length=16, 90 | hmac_key=self.hmac_key, 91 | salt="somesalt", 92 | number=0, 93 | ) 94 | challenge = create_challenge(options) 95 | payload = Payload( 96 | algorithm="SHA-256", 97 | challenge=challenge.challenge, 98 | number=0, 99 | salt="somesalt", 100 | signature=challenge.signature, 101 | ) 102 | payload_encoded = base64.b64encode( 103 | json.dumps(payload.__dict__).encode() 104 | ).decode() 105 | result, _ = verify_solution(payload_encoded, self.hmac_key, check_expires=False) 106 | self.assertTrue(result) 107 | 108 | # Test for number being inclusive with max_number 109 | def test_verify_solution_max_number(self): 110 | options = ChallengeOptions( 111 | algorithm="SHA-256", 112 | max_number=10, 113 | salt_length=16, 114 | hmac_key=self.hmac_key, 115 | salt="somesalt", 116 | number=10, 117 | ) 118 | challenge = create_challenge(options) 119 | payload = Payload( 120 | algorithm="SHA-256", 121 | challenge=challenge.challenge, 122 | number=10, 123 | salt="somesalt", 124 | signature=challenge.signature, 125 | ) 126 | payload_encoded = base64.b64encode( 127 | json.dumps(payload.__dict__).encode() 128 | ).decode() 129 | result, _ = verify_solution(payload_encoded, self.hmac_key, check_expires=False) 130 | self.assertTrue(result) 131 | 132 | def test_verify_solution_not_expired(self): 133 | options = ChallengeOptions( 134 | algorithm="SHA-256", 135 | max_number=1000, 136 | salt_length=16, 137 | hmac_key=self.hmac_key, 138 | salt="somesalt", 139 | number=123, 140 | expires=datetime.datetime.now().astimezone() 141 | + datetime.timedelta(minutes=1), 142 | ) 143 | challenge = create_challenge(options) 144 | payload = Payload( 145 | algorithm="SHA-256", 146 | challenge=challenge.challenge, 147 | number=123, 148 | salt=challenge.salt, 149 | signature=challenge.signature, 150 | ) 151 | payload_encoded = base64.b64encode( 152 | json.dumps(payload.__dict__).encode() 153 | ).decode() 154 | result, _ = verify_solution(payload_encoded, self.hmac_key, check_expires=True) 155 | self.assertTrue(result) 156 | 157 | def test_verify_solution_expired(self): 158 | options = ChallengeOptions( 159 | algorithm="SHA-256", 160 | max_number=1000, 161 | salt_length=16, 162 | hmac_key=self.hmac_key, 163 | salt="somesalt", 164 | number=123, 165 | expires=datetime.datetime.now().astimezone() 166 | - datetime.timedelta(minutes=1), 167 | ) 168 | challenge = create_challenge(options) 169 | payload = Payload( 170 | algorithm="SHA-256", 171 | challenge=challenge.challenge, 172 | number=123, 173 | salt=challenge.salt, 174 | signature=challenge.signature, 175 | ) 176 | payload_encoded = base64.b64encode( 177 | json.dumps(payload.__dict__).encode() 178 | ).decode() 179 | result, _ = verify_solution(payload_encoded, self.hmac_key, check_expires=True) 180 | self.assertFalse(result) 181 | 182 | def test_verify_solution_malformed_expiry(self): 183 | options = ChallengeOptions( 184 | algorithm="SHA-256", 185 | max_number=1000, 186 | salt_length=16, 187 | hmac_key=self.hmac_key, 188 | salt="somesalt", 189 | number=123, 190 | expires=datetime.datetime.now().astimezone() 191 | + datetime.timedelta(minutes=1), 192 | ) 193 | challenge = create_challenge(options) 194 | payload = Payload( 195 | algorithm="SHA-256", 196 | challenge=challenge.challenge, 197 | number=123, 198 | salt="somesalt?expires=foobar", 199 | signature=challenge.signature, 200 | ) 201 | payload_encoded = base64.b64encode( 202 | json.dumps(payload.__dict__).encode() 203 | ).decode() 204 | result, _ = verify_solution(payload_encoded, self.hmac_key, check_expires=True) 205 | self.assertFalse(result) 206 | 207 | def test_valid_signature(self): 208 | expire_time = int(time.time()) + 600 # 10 minutes from now 209 | verification_data = ( 210 | f"expire={expire_time}&fields=field1,field2&reasons=reason1,reason2" 211 | f"&score=3&time={int(time.time())}&verified=true" 212 | f"&location.countryCode=US" 213 | ) 214 | hash_obj = hash_algorithm("SHA-256") 215 | hash_obj.update(verification_data.encode()) 216 | expected_signature = hmac_hex("SHA-256", hash_obj.digest(), self.hmac_key) 217 | 218 | payload = { 219 | "algorithm": "SHA-256", 220 | "verificationData": verification_data, 221 | "signature": expected_signature, 222 | "verified": True, 223 | } 224 | payload_encoded = base64.b64encode(json.dumps(payload).encode()).decode() 225 | 226 | is_valid, data, error = verify_server_signature(payload_encoded, self.hmac_key) 227 | 228 | self.assertIsNone(error) 229 | self.assertTrue(is_valid) 230 | self.assertGreater(int(data.expire), 0) 231 | self.assertGreater(len(data.fields), 0) 232 | self.assertGreater(len(data.reasons), 0) 233 | self.assertGreater(int(data.score), 0) 234 | self.assertGreater(int(data.time), 0) 235 | self.assertTrue(data.verified) 236 | self.assertEqual(getattr(data, "location.countryCode"), "US") 237 | 238 | def test_invalid_signature(self): 239 | expire_time = int(time.time()) + 600 240 | verification_data = ( 241 | f"expire={expire_time}&fields=field1,field2&reasons=reason1,reason2" 242 | f"&score=3&time={int(time.time())}&verified=true" 243 | ) 244 | payload = { 245 | "algorithm": "SHA-256", 246 | "verificationData": verification_data, 247 | "signature": "invalidSignature", 248 | "verified": True, 249 | } 250 | payload_encoded = base64.b64encode(json.dumps(payload).encode()).decode() 251 | 252 | is_valid, _, error = verify_server_signature(payload_encoded, self.hmac_key) 253 | 254 | self.assertIsNone(error) 255 | self.assertFalse(is_valid) 256 | 257 | def test_expired_payload(self): 258 | expire_time = int(time.time()) - 600 # 10 minutes ago 259 | verification_data = ( 260 | f"expire={expire_time}&fields=field1,field2&reasons=reason1,reason2" 261 | f"&score=3&time={int(time.time())}&verified=true" 262 | ) 263 | hash_obj = hash_algorithm("SHA-256") 264 | hash_obj.update(verification_data.encode()) 265 | expected_signature = hmac_hex("SHA-256", hash_obj.digest(), self.hmac_key) 266 | 267 | payload = { 268 | "algorithm": "SHA-256", 269 | "verificationData": verification_data, 270 | "signature": expected_signature, 271 | "verified": True, 272 | } 273 | payload_encoded = base64.b64encode(json.dumps(payload).encode()).decode() 274 | 275 | is_valid, _, error = verify_server_signature(payload_encoded, self.hmac_key) 276 | 277 | self.assertIsNotNone(error) 278 | self.assertFalse(is_valid) 279 | 280 | def test_solve_challenge(self): 281 | start = 0 282 | options = ChallengeOptions( 283 | algorithm="SHA-256", 284 | max_number=1000, 285 | salt_length=16, 286 | hmac_key=self.hmac_key, 287 | salt="somesalt", 288 | number=123, 289 | ) 290 | challenge = create_challenge(options) 291 | 292 | solution = solve_challenge( 293 | challenge.challenge, 294 | challenge.salt, 295 | challenge.algorithm, 296 | 1000, 297 | start, 298 | ) 299 | 300 | self.assertIsNotNone(solution, "Solution should not be None") 301 | self.assertEqual( 302 | challenge.challenge, 303 | hash_hex( 304 | challenge.algorithm, (challenge.salt + str(solution.number)).encode() 305 | ), 306 | ) 307 | 308 | def test_solve_challenge_solution(self): 309 | # Create a challenge 310 | options = ChallengeOptions( 311 | algorithm="SHA-256", number=100, hmac_key=self.hmac_key 312 | ) 313 | 314 | challenge = create_challenge(options) 315 | 316 | solution = solve_challenge( 317 | challenge.challenge, 318 | challenge.salt, 319 | challenge.algorithm, 320 | challenge.max_number, 321 | 0, 322 | ) 323 | 324 | # Verify the solution 325 | self.assertIsNotNone(solution, "Solution should not be None") 326 | self.assertEqual(solution.number, 100, "Solution be 100") 327 | 328 | def test_hash_hex(self): 329 | result = hash_hex("SHA-256", "testdata".encode()) 330 | self.assertEqual(result, hashlib.sha256("testdata".encode()).hexdigest()) 331 | 332 | def test_hmac_hex(self): 333 | result = hmac_hex("SHA-256", "testdata".encode(), self.hmac_key) 334 | expected = hmac.new( 335 | self.hmac_key.encode(), "testdata".encode(), hashlib.sha256 336 | ).hexdigest() 337 | self.assertEqual(result, expected) 338 | 339 | 340 | if __name__ == "__main__": 341 | unittest.main() 342 | --------------------------------------------------------------------------------