├── tests ├── __init__.py ├── test_cli │ ├── __init__.py │ ├── test_main.py │ ├── test_remove.py │ ├── test_compile.py │ ├── test_enc_dec.py │ ├── test_sign_verify.py │ ├── test_keygen.py │ ├── conftest.py │ └── test_tools.py ├── test_pqa │ ├── __init__.py │ ├── test_kem.py │ ├── conftest.py │ └── test_dss.py ├── test_cipher │ ├── __init__.py │ ├── conftest.py │ ├── test_krypton_file.py │ ├── test_krypton.py │ └── test_krypton_kem.py ├── conftest.py ├── test_kdf │ ├── test_kdf_common.py │ ├── test_argon2.py │ └── test_kkdf.py ├── test_compiler.py ├── test_errors.py ├── test_chunk_size.py ├── test_utils.py ├── test_pqclean.py └── test_constants.py ├── quantcrypt ├── pqclean │ └── .gitkeep ├── internal │ ├── bin │ │ └── .gitkeep │ ├── cli │ │ ├── main.py │ │ ├── commands │ │ │ ├── info.py │ │ │ ├── keygen.py │ │ │ ├── compile.py │ │ │ ├── enc_dec.py │ │ │ ├── sign_verify.py │ │ │ └── remove.py │ │ ├── console.py │ │ ├── tools.py │ │ └── annotations.py │ ├── chunksize.py │ ├── kdf │ │ ├── kmac_kdf.py │ │ └── common.py │ ├── utils.py │ ├── pqa │ │ ├── kem_algos.py │ │ ├── base_kem.py │ │ ├── common.py │ │ └── dss_algos.py │ ├── errors.py │ ├── constants.py │ └── pqclean.py ├── __init__.py ├── compiler.py ├── utils.py ├── kdf.py ├── cipher.py ├── kem.py ├── dss.py └── errors.py ├── .gitmodules ├── docs └── images │ ├── cnsa2-timeline.png │ ├── quantcrypt-logo.jpg │ └── quantcrypt-logo.png ├── .devtools ├── .github └── workflows │ ├── trivy-scanner.yml │ ├── pytest-codecov.yml │ └── build-publish.yml ├── LICENSE ├── scripts └── build.py ├── .gitignore ├── pyproject.toml ├── CHANGELOG.md └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /quantcrypt/pqclean/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_cli/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /quantcrypt/internal/bin/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "pqclean"] 2 | path = pqclean 3 | url = https://github.com/PQClean/PQClean 4 | -------------------------------------------------------------------------------- /docs/images/cnsa2-timeline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aabmets/quantcrypt/HEAD/docs/images/cnsa2-timeline.png -------------------------------------------------------------------------------- /docs/images/quantcrypt-logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aabmets/quantcrypt/HEAD/docs/images/quantcrypt-logo.jpg -------------------------------------------------------------------------------- /docs/images/quantcrypt-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aabmets/quantcrypt/HEAD/docs/images/quantcrypt-logo.png -------------------------------------------------------------------------------- /tests/test_pqa/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | -------------------------------------------------------------------------------- /quantcrypt/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | -------------------------------------------------------------------------------- /tests/test_cipher/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | -------------------------------------------------------------------------------- /quantcrypt/compiler.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | from quantcrypt.internal.compiler import Target, Compiler 13 | 14 | 15 | __all__ = ["Target", "Compiler"] 16 | -------------------------------------------------------------------------------- /.devtools: -------------------------------------------------------------------------------- 1 | { 2 | "license_cmd": { 3 | "header": { 4 | "title": "MIT License", 5 | "year": "2024", 6 | "holder": "Mattias Aabmets", 7 | "spdx_id": "MIT", 8 | "spaces": 3, 9 | "oss": true 10 | }, 11 | "paths": [ 12 | "quantcrypt", 13 | "scripts", 14 | "tests" 15 | ], 16 | "file_name": "mit.json" 17 | }, 18 | "version_cmd": { 19 | "app_version": "1.0.1", 20 | "track_descriptor": true, 21 | "track_chart": false, 22 | "components": [ 23 | { 24 | "name": "lib", 25 | "target": "quantcrypt", 26 | "ignore": [], 27 | "hash": "ae78d287ff337520c797f345f9752772" 28 | } 29 | ] 30 | }, 31 | "log_cmd": { 32 | "gh_user": "aabmets", 33 | "gh_repo": "quantcrypt" 34 | } 35 | } -------------------------------------------------------------------------------- /quantcrypt/utils.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | from quantcrypt.internal.cipher.krypton_file import DecryptedFile 13 | from quantcrypt.internal.kdf.common import MemCost, KDFParams 14 | from quantcrypt.internal.constants import PQAVariant, SupportedAlgos 15 | from quantcrypt.internal.pqa.base_dss import SignedFile 16 | from quantcrypt.internal.chunksize import ChunkSize 17 | 18 | 19 | __all__ = [ 20 | "DecryptedFile", 21 | "MemCost", 22 | "KDFParams", 23 | "PQAVariant", 24 | "SupportedAlgos", 25 | "SignedFile", 26 | "ChunkSize" 27 | ] 28 | -------------------------------------------------------------------------------- /.github/workflows/trivy-scanner.yml: -------------------------------------------------------------------------------- 1 | name: Trivy Security Scanner 2 | 3 | on: 4 | workflow_dispatch: 5 | workflow_call: 6 | 7 | jobs: 8 | main: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: read 12 | security-events: write 13 | 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v4 17 | with: 18 | submodules: 'true' 19 | 20 | - name: Scan repo with Trivy 21 | uses: aquasecurity/trivy-action@master 22 | with: 23 | scan-type: 'fs' 24 | ignore-unfixed: true 25 | format: 'sarif' 26 | output: 'trivy-results.sarif' 27 | severity: 'HIGH' 28 | 29 | - name: Upload Trivy scan results 30 | uses: github/codeql-action/upload-sarif@v2 31 | with: 32 | sarif_file: 'trivy-results.sarif' 33 | -------------------------------------------------------------------------------- /.github/workflows/pytest-codecov.yml: -------------------------------------------------------------------------------- 1 | name: Run Pytest and Upload Code Coverage 2 | 3 | on: 4 | workflow_dispatch: 5 | workflow_call: 6 | 7 | jobs: 8 | main: 9 | name: pytest-codecov 10 | runs-on: ubuntu-latest 11 | env: 12 | CODECOV: "true" 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v4 16 | with: 17 | submodules: "true" 18 | 19 | - name: Setup UV package manager 20 | uses: astral-sh/setup-uv@v5 21 | with: 22 | python-version: "3.13" 23 | version: "latest" 24 | 25 | - name: Run commands 26 | run: | 27 | uv sync --group develop --frozen 28 | qclib compile -N 29 | pytest 30 | 31 | - name: Upload code coverage 32 | uses: codecov/codecov-action@v4 33 | env: 34 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 35 | -------------------------------------------------------------------------------- /quantcrypt/kdf.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | from quantcrypt.internal.kdf.common import MemCost, KDFParams 13 | from quantcrypt.internal.kdf.argon2_kdf import Argon2 14 | from quantcrypt.internal.kdf.kmac_kdf import KKDF 15 | from quantcrypt.internal.errors import ( 16 | KDFOutputLimitError, 17 | KDFWeakPasswordError, 18 | KDFVerificationError, 19 | KDFInvalidHashError, 20 | KDFHashingError, 21 | KDFError 22 | ) 23 | 24 | 25 | __all__ = [ 26 | "MemCost", 27 | "KDFParams", 28 | "Argon2", 29 | "KKDF", 30 | "KDFOutputLimitError", 31 | "KDFWeakPasswordError", 32 | "KDFVerificationError", 33 | "KDFInvalidHashError", 34 | "KDFHashingError", 35 | "KDFError" 36 | ] 37 | -------------------------------------------------------------------------------- /tests/test_cipher/conftest.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | import os 13 | import pytest 14 | from pathlib import Path 15 | from dotmap import DotMap 16 | 17 | 18 | @pytest.fixture(scope="function") 19 | def krypton_file_helpers(tmp_path: Path) -> DotMap: 20 | orig_pt = os.urandom(1024 * 16) 21 | pt_file = tmp_path / "test_file.bin" 22 | ct_file = tmp_path / "test_file.enc" 23 | pt2_file = tmp_path / "test_file2.bin" 24 | counter = list() 25 | 26 | with pt_file.open("wb") as file: 27 | file.write(orig_pt) 28 | 29 | def callback(): 30 | counter.append(1) 31 | 32 | return DotMap( 33 | sk=b'x' * 64, 34 | tmp_path=tmp_path, 35 | orig_pt=orig_pt, 36 | pt_file=pt_file, 37 | ct_file=ct_file, 38 | pt2_file=pt2_file, 39 | counter=counter, 40 | callback=callback 41 | ) 42 | -------------------------------------------------------------------------------- /quantcrypt/cipher.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | from quantcrypt.internal.chunksize import ChunkSize 13 | from quantcrypt.internal.cipher.krypton import Krypton 14 | from quantcrypt.internal.cipher.krypton_file import KryptonFile, DecryptedFile 15 | from quantcrypt.internal.cipher.krypton_kem import KryptonKEM 16 | from quantcrypt.internal.errors import ( 17 | CipherError, 18 | CipherStateError, 19 | CipherVerifyError, 20 | CipherChunkSizeError, 21 | CipherPaddingError 22 | ) 23 | 24 | 25 | __all__ = [ 26 | "ChunkSize", 27 | "Krypton", 28 | "KryptonFile", 29 | "DecryptedFile", 30 | "KryptonKEM", 31 | "CipherError", 32 | "CipherStateError", 33 | "CipherVerifyError", 34 | "CipherChunkSizeError", 35 | "CipherPaddingError" 36 | ] 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | 3 | MIT License 4 | 5 | Copyright (c) 2024 Mattias Aabmets 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | import re 13 | import string 14 | import pytest 15 | import secrets 16 | import tempfile 17 | from pathlib import Path 18 | 19 | 20 | @pytest.fixture(name="alt_tmp_path", scope="function") 21 | def fixture_alt_tmp_path(tmp_path) -> Path: 22 | base_path = Path(tempfile.gettempdir()) 23 | 24 | match = re.search(r"/pytest-(\d+)/", tmp_path.as_posix()) 25 | pytest_dir = "qc_pytest" + ('_' + match.group(1) if match else '') 26 | 27 | charset = string.ascii_letters + string.digits 28 | test_dir = ''.join([secrets.choice(charset) for _ in range(20)]) 29 | 30 | test_path = base_path / pytest_dir / test_dir 31 | if test_path.exists(): 32 | raise RuntimeError(f"Cannot reuse existing temp test path: {test_path}") 33 | 34 | test_path.mkdir(parents=True, exist_ok=True) 35 | return test_path 36 | -------------------------------------------------------------------------------- /quantcrypt/kem.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | from quantcrypt.internal.constants import PQAVariant 13 | from quantcrypt.internal.pqa.base_kem import KEMParamSizes, BaseKEM 14 | from quantcrypt.internal.pqa.kem_algos import ( 15 | MLKEM_512, 16 | MLKEM_768, 17 | MLKEM_1024 18 | ) 19 | from quantcrypt.internal.errors import ( 20 | PQAError, 21 | PQAImportError, 22 | PQAUnsupportedAlgoError, 23 | PQAKeyArmorError, 24 | KEMKeygenFailedError, 25 | KEMEncapsFailedError, 26 | KEMDecapsFailedError 27 | ) 28 | 29 | 30 | __all__ = [ 31 | "PQAVariant", 32 | "KEMParamSizes", 33 | "BaseKEM", 34 | "MLKEM_512", 35 | "MLKEM_768", 36 | "MLKEM_1024", 37 | "PQAError", 38 | "PQAImportError", 39 | "PQAUnsupportedAlgoError", 40 | "PQAKeyArmorError", 41 | "KEMKeygenFailedError", 42 | "KEMEncapsFailedError", 43 | "KEMDecapsFailedError" 44 | ] 45 | -------------------------------------------------------------------------------- /scripts/build.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | import argparse 13 | from typing import Any 14 | from packaging import tags 15 | from hatchling.builders.hooks.plugin.interface import BuildHookInterface 16 | 17 | 18 | class CustomBuildHook(BuildHookInterface): 19 | def initialize(self, version: str, build_data: dict[str, Any]) -> None: 20 | first_tag = list(tags.sys_tags())[0] 21 | build_data["pure_python"] = False 22 | build_data["infer_tag"] = False 23 | build_data["tag"] = '-'.join([ 24 | first_tag.interpreter, 25 | first_tag.abi, 26 | first_tag.platform 27 | ]) 28 | 29 | 30 | if __name__ == "__main__": 31 | parser = argparse.ArgumentParser() 32 | parser.add_argument("--compile", action="store_true", default=False) 33 | args = parser.parse_args() 34 | 35 | if args.compile: 36 | from quantcrypt.internal.compiler import Compiler 37 | Compiler.run(verbose=True) 38 | -------------------------------------------------------------------------------- /tests/test_kdf/test_kdf_common.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | import pytest 13 | from typing import Literal, cast 14 | from pydantic import ValidationError 15 | from quantcrypt.kdf import MemCost, Argon2 16 | from quantcrypt.internal import errors 17 | 18 | 19 | def test_mem_cost_mb_values(): 20 | for value in range(513): 21 | c_val = cast(Literal, value) 22 | if value in [32, 64, 128, 256, 512]: 23 | assert MemCost.MB(c_val).get("value") == 1024 * value 24 | else: 25 | with pytest.raises(ValidationError): 26 | MemCost.MB(c_val) 27 | 28 | 29 | def test_mem_cost_gb_values(): 30 | valid_values = [x for x in range(1, 9)] 31 | for value in range(-10, 10): 32 | c_val = cast(Literal, value) 33 | if value in valid_values: 34 | assert MemCost.GB(c_val).get("value") == 1024 ** 2 * value 35 | continue 36 | with pytest.raises(ValidationError): 37 | MemCost.GB(c_val) 38 | 39 | 40 | def test_invalid_usage(): 41 | with pytest.raises(errors.InvalidUsageError): 42 | MemCost() 43 | with pytest.raises(errors.InvalidUsageError): 44 | Argon2() 45 | -------------------------------------------------------------------------------- /quantcrypt/dss.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | from quantcrypt.internal.chunksize import ChunkSize 13 | from quantcrypt.internal.constants import PQAVariant 14 | from quantcrypt.internal.pqa.base_dss import DSSParamSizes, BaseDSS 15 | from quantcrypt.internal.pqa.dss_algos import ( 16 | MLDSA_44, 17 | MLDSA_65, 18 | MLDSA_87, 19 | FALCON_512, 20 | FALCON_1024, 21 | FAST_SPHINCS, 22 | SMALL_SPHINCS 23 | ) 24 | from quantcrypt.internal.errors import ( 25 | PQAError, 26 | PQAImportError, 27 | PQAUnsupportedAlgoError, 28 | PQAKeyArmorError, 29 | DSSKeygenFailedError, 30 | DSSSignFailedError, 31 | DSSVerifyFailedError 32 | ) 33 | 34 | 35 | __all__ = [ 36 | "ChunkSize", 37 | "PQAVariant", 38 | "DSSParamSizes", 39 | "BaseDSS", 40 | "MLDSA_44", 41 | "MLDSA_65", 42 | "MLDSA_87", 43 | "FALCON_512", 44 | "FALCON_1024", 45 | "FAST_SPHINCS", 46 | "SMALL_SPHINCS", 47 | "PQAError", 48 | "PQAImportError", 49 | "PQAUnsupportedAlgoError", 50 | "PQAKeyArmorError", 51 | "DSSKeygenFailedError", 52 | "DSSSignFailedError", 53 | "DSSVerifyFailedError" 54 | ] 55 | -------------------------------------------------------------------------------- /tests/test_cli/test_main.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | import tomli 13 | import pytest 14 | from dotmap import DotMap 15 | from typer.testing import CliRunner 16 | from quantcrypt.internal import utils 17 | from quantcrypt.internal.cli.main import app 18 | 19 | 20 | @pytest.fixture(name="project", scope="module") 21 | def fixture_project() -> DotMap: 22 | path = utils.search_upwards("pyproject.toml") 23 | contents = tomli.loads(path.read_text()) 24 | return DotMap(contents["project"]) 25 | 26 | 27 | def test_main_version(project: DotMap): 28 | runner = CliRunner() 29 | result = runner.invoke(app, ["--version"]) 30 | 31 | assert result.exit_code == 0 32 | assert result.stdout.strip() == project.version 33 | 34 | 35 | def test_main_info(project: DotMap): 36 | runner = CliRunner() 37 | result = runner.invoke(app, ["info"]) 38 | 39 | assert result.exit_code == 0 40 | assert project.name in result.stdout 41 | assert project.version in result.stdout 42 | assert project.description in result.stdout 43 | assert project.license in result.stdout 44 | assert project.urls.Repository in result.stdout 45 | 46 | author = project.authors[0] 47 | assert author.name in result.stdout 48 | assert author.email in result.stdout 49 | -------------------------------------------------------------------------------- /tests/test_cli/test_remove.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | from pathlib import Path 13 | from unittest.mock import patch 14 | from quantcrypt.internal import constants as const 15 | from .conftest import CLIMessages 16 | 17 | 18 | def test_remove(cli_runner, alt_tmp_path) -> None: 19 | with patch('internal.cli.commands.remove.utils.search_upwards') as mock: 20 | mock.return_value = alt_tmp_path 21 | 22 | cli_runner("remove", ["mlkem512"], "n\n", CLIMessages.CANCELLED) 23 | cli_runner("remove", ["-D", "mlkem512"], "y\n", CLIMessages.DRYRUN) 24 | cli_runner("remove", ["--only-ref", "mlkem512"], "", CLIMessages.ERROR) 25 | 26 | for spec in const.SupportedAlgos: # type: const.AlgoSpec 27 | (alt_tmp_path / spec.module_name(const.PQAVariant.REF)).touch(exist_ok=True) 28 | 29 | cli_runner("remove", ["mlkem512"], "y\n", CLIMessages.SUCCESS) 30 | 31 | spec = const.SupportedAlgos.filter(["mlkem512"])[0] 32 | for item in alt_tmp_path.iterdir(): # type: Path 33 | for variant in const.PQAVariant.members(): 34 | assert spec.module_name(variant) not in item.name 35 | 36 | cli_runner("remove", ["asdfg"], "y\n", CLIMessages.ERROR) 37 | cli_runner("remove", ["-N", "fastsphincs"], "", CLIMessages.SUCCESS) 38 | cli_runner("remove", ["--keep", "--only-ref", "mlkem512"], "y\n", CLIMessages.SUCCESS) 39 | 40 | -------------------------------------------------------------------------------- /tests/test_compiler.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | from pathlib import Path 13 | from unittest.mock import patch 14 | from quantcrypt.internal import constants as const 15 | from quantcrypt.compiler import Compiler 16 | 17 | 18 | def test_compiler_run(alt_tmp_path): 19 | def _mocked_search_upwards(_path: Path | str): 20 | sub_path = alt_tmp_path / _path 21 | sub_path.mkdir(parents=True, exist_ok=True) 22 | return sub_path 23 | 24 | with patch('internal.compiler.utils.search_upwards') as mock: 25 | mock.side_effect = _mocked_search_upwards 26 | 27 | bin_path = alt_tmp_path / "bin" 28 | old_file = bin_path / "old_file" 29 | old_folder = bin_path / "old_folder" 30 | old_folder.mkdir(parents=True) 31 | old_file.touch() 32 | 33 | sup_alg_len = len(const.SupportedAlgos) 34 | variant_len = len(const.PQAVariant.values()) 35 | variants = [const.PQAVariant.REF] 36 | 37 | rejected = Compiler().run(variants, []) 38 | assert len(rejected) == (sup_alg_len * variant_len) 39 | assert old_file.exists() and old_folder.exists() 40 | assert len(list(bin_path.iterdir())) == 2 41 | 42 | algos = const.SupportedAlgos.filter(["MLKEM512", "MLDSA44"]) 43 | rejected = Compiler().run(variants, algos) 44 | assert len(rejected) == (sup_alg_len * variant_len - 2) 45 | assert not old_file.exists() and not old_folder.exists() 46 | assert len(list(bin_path.iterdir())) == 2 47 | -------------------------------------------------------------------------------- /quantcrypt/errors.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | from quantcrypt.internal.errors import ( 13 | QuantCryptError, 14 | InvalidUsageError, 15 | InvalidArgsError, 16 | UnsupportedPlatformError, 17 | 18 | PQAError, 19 | PQAImportError, 20 | PQAUnsupportedAlgoError, 21 | PQAKeyArmorError, 22 | KEMKeygenFailedError, 23 | KEMEncapsFailedError, 24 | KEMDecapsFailedError, 25 | DSSKeygenFailedError, 26 | DSSSignFailedError, 27 | DSSVerifyFailedError, 28 | 29 | KDFError, 30 | KDFOutputLimitError, 31 | KDFWeakPasswordError, 32 | KDFVerificationError, 33 | KDFInvalidHashError, 34 | KDFHashingError, 35 | 36 | CipherError, 37 | CipherStateError, 38 | CipherVerifyError, 39 | CipherChunkSizeError, 40 | CipherPaddingError 41 | ) 42 | 43 | 44 | __all__ = [ 45 | "QuantCryptError", 46 | "InvalidUsageError", 47 | "InvalidArgsError", 48 | "UnsupportedPlatformError", 49 | 50 | "PQAError", 51 | "PQAImportError", 52 | "PQAUnsupportedAlgoError", 53 | "PQAKeyArmorError", 54 | "KEMKeygenFailedError", 55 | "KEMEncapsFailedError", 56 | "KEMDecapsFailedError", 57 | "DSSKeygenFailedError", 58 | "DSSSignFailedError", 59 | "DSSVerifyFailedError", 60 | 61 | "KDFError", 62 | "KDFOutputLimitError", 63 | "KDFWeakPasswordError", 64 | "KDFVerificationError", 65 | "KDFInvalidHashError", 66 | "KDFHashingError", 67 | 68 | "CipherError", 69 | "CipherStateError", 70 | "CipherVerifyError", 71 | "CipherChunkSizeError", 72 | "CipherPaddingError" 73 | ] 74 | -------------------------------------------------------------------------------- /tests/test_cli/test_compile.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | from unittest.mock import patch 13 | from quantcrypt.internal import constants as const 14 | from .conftest import CLIMessages 15 | 16 | 17 | class MockedProcess: 18 | def __init__(self, returncode: int): 19 | self._returncode = returncode 20 | 21 | @property 22 | def stdout(self) -> list[str]: 23 | return [ 24 | "Some garbage output from CFFI...", 25 | f"{const.SubprocTag}Compiling clean variant of MLKEM512...", 26 | "More garbage output from CFFI..." 27 | ] 28 | 29 | @property 30 | def returncode(self) -> int: 31 | return int(self._returncode) 32 | 33 | def wait(self) -> None: 34 | return 35 | 36 | 37 | class MockedCompiler: 38 | _returncode = 1 39 | 40 | @classmethod 41 | def run(cls, *_, **kwargs) -> MockedProcess: 42 | assert "in_subprocess" in kwargs and kwargs["in_subprocess"] is True 43 | cls._returncode = not cls._returncode 44 | return MockedProcess(cls._returncode) 45 | 46 | 47 | def test_compile(cli_runner, alt_tmp_path) -> None: 48 | target = "internal.cli.commands.compile.compiler.Compiler" 49 | patcher = patch(target, new=MockedCompiler) 50 | 51 | patcher.start() 52 | cli_runner("compile", [], "n\n", CLIMessages.CANCELLED) 53 | cli_runner("compile", ['-o', 'mlkem512'], "y\n", CLIMessages.SUCCESS) 54 | cli_runner("compile", ['-D', 'mlkem512'], "y\n", CLIMessages.DRYRUN) 55 | cli_runner("compile", ['-N', 'mlkem512'], "", CLIMessages.ERROR) 56 | cli_runner("compile", ['-N', 'mlkem512'], "", CLIMessages.SUCCESS) 57 | patcher.stop() 58 | -------------------------------------------------------------------------------- /quantcrypt/internal/cli/main.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | import inspect 13 | import importlib 14 | from typer import Typer 15 | from types import ModuleType 16 | from typing import Generator 17 | from pathlib import Path 18 | from importlib.util import find_spec 19 | from quantcrypt.internal import utils 20 | from quantcrypt.internal.cli import annotations as atd 21 | from quantcrypt.internal.cli.commands.info import PackageInfo 22 | 23 | 24 | app = Typer( 25 | name="qclib", 26 | no_args_is_help=True, 27 | invoke_without_command=True 28 | ) 29 | 30 | 31 | @app.callback() 32 | def main(version: atd.Version = False) -> None: 33 | if version: 34 | print(PackageInfo().Version) 35 | 36 | 37 | def find_command_modules() -> Generator[ModuleType, None, None]: 38 | package_path = utils.search_upwards("quantcrypt/__init__.py").parent 39 | import_dir = Path(__file__).with_name("commands") 40 | for filepath in import_dir.rglob("*.py"): 41 | if filepath.name == "compile.py": 42 | required_packages = ["cffi", "setuptools", "yaml", "requests"] 43 | if not all(find_spec(pkg) is not None for pkg in required_packages): # pragma: no cover 44 | continue 45 | relative_path = filepath.resolve().relative_to(package_path) 46 | module_path = '.'.join(relative_path.with_suffix('').parts) 47 | yield importlib.import_module( 48 | package=package_path.name, 49 | name=f'.{module_path}' 50 | ) 51 | 52 | 53 | for module in find_command_modules(): 54 | for _, obj in inspect.getmembers(module): 55 | if isinstance(obj, Typer): 56 | app.add_typer(typer_instance=obj, name=obj.info.name) 57 | -------------------------------------------------------------------------------- /tests/test_errors.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | from quantcrypt.internal import constants as const 13 | from quantcrypt.errors import ( 14 | QuantCryptError, 15 | InvalidUsageError, 16 | InvalidArgsError, 17 | UnsupportedPlatformError, 18 | 19 | PQAError, 20 | PQAImportError, 21 | PQAUnsupportedAlgoError, 22 | PQAKeyArmorError, 23 | KEMKeygenFailedError, 24 | KEMEncapsFailedError, 25 | KEMDecapsFailedError, 26 | DSSKeygenFailedError, 27 | DSSSignFailedError, 28 | DSSVerifyFailedError, 29 | 30 | KDFError, 31 | KDFOutputLimitError, 32 | KDFWeakPasswordError, 33 | KDFVerificationError, 34 | KDFInvalidHashError, 35 | KDFHashingError, 36 | 37 | CipherError, 38 | CipherStateError, 39 | CipherVerifyError, 40 | CipherChunkSizeError, 41 | CipherPaddingError 42 | ) 43 | 44 | 45 | def test_error_instantiation(): 46 | assert QuantCryptError() 47 | assert InvalidUsageError() 48 | assert InvalidArgsError() 49 | assert UnsupportedPlatformError() 50 | 51 | assert PQAError() 52 | assert PQAImportError(const.SupportedAlgos[0], const.PQAVariant.REF) 53 | assert PQAUnsupportedAlgoError("asdfg") 54 | assert PQAKeyArmorError("armor") 55 | assert PQAKeyArmorError("dearmor") 56 | assert KEMKeygenFailedError() 57 | assert KEMEncapsFailedError() 58 | assert KEMDecapsFailedError() 59 | assert DSSKeygenFailedError() 60 | assert DSSSignFailedError() 61 | assert DSSVerifyFailedError() 62 | 63 | assert KDFError() 64 | assert KDFOutputLimitError(0) 65 | assert KDFWeakPasswordError() 66 | assert KDFVerificationError() 67 | assert KDFInvalidHashError() 68 | assert KDFHashingError() 69 | 70 | assert CipherError() 71 | assert CipherStateError() 72 | assert CipherVerifyError() 73 | assert CipherChunkSizeError() 74 | assert CipherPaddingError() 75 | -------------------------------------------------------------------------------- /.github/workflows/build-publish.yml: -------------------------------------------------------------------------------- 1 | name: Build Wheels and Publish to PyPI 2 | 3 | on: 4 | workflow_dispatch: 5 | workflow_call: 6 | 7 | concurrency: 8 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 9 | cancel-in-progress: true 10 | 11 | jobs: 12 | build: 13 | runs-on: ${{ matrix.os }} 14 | strategy: 15 | matrix: 16 | os: [ ubuntu-latest, windows-latest, macos-latest ] 17 | python: [ "3.10", "3.11", "3.12", "3.13" ] 18 | 19 | steps: 20 | - name: Checkout repository 21 | uses: actions/checkout@v4 22 | with: 23 | submodules: "true" 24 | 25 | - name: Setup UV package manager 26 | uses: astral-sh/setup-uv@v5 27 | with: 28 | python-version: ${{ matrix.python }} 29 | version: "latest" 30 | 31 | - name: Install dependencies 32 | run: uv sync --frozen --group develop 33 | 34 | - name: Build sdist (conditional) 35 | if: ${{ matrix.os == 'ubuntu-latest' && matrix.python == '3.13' }} 36 | run: uv build --sdist --out-dir dist 37 | 38 | - name: Compile binaries 39 | run: python scripts/build.py --compile 40 | 41 | - name: Run pytests (conditional) 42 | if: ${{ matrix.os != 'macos-latest' }} 43 | run: pytest --no-cov 44 | 45 | - name: Build wheel 46 | run: uv build --wheel --out-dir dist 47 | 48 | - name: Upload built packages 49 | uses: actions/upload-artifact@v4 50 | with: 51 | name: pkg-${{ matrix.os }}-${{ matrix.python }}-${{ strategy.job-index }} 52 | path: | 53 | ./dist/*.whl 54 | ./dist/*.tar.gz 55 | 56 | publish: 57 | needs: build 58 | runs-on: ubuntu-latest 59 | 60 | environment: 61 | name: release 62 | url: https://pypi.org/project/quantcrypt/ 63 | 64 | permissions: 65 | id-token: write 66 | 67 | steps: 68 | - name: Download built packages 69 | uses: actions/download-artifact@v4 70 | with: 71 | path: dist 72 | merge-multiple: true 73 | 74 | - name: Publish package to PyPI 75 | uses: pypa/gh-action-pypi-publish@release/v1 76 | -------------------------------------------------------------------------------- /tests/test_chunk_size.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | import pytest 13 | import typing as t 14 | from pydantic import ValidationError 15 | from quantcrypt.internal import errors 16 | from quantcrypt.internal.chunksize import ( 17 | ChunkSize, ChunkSizeKB, ChunkSizeMB, KBLiteral, MBLiteral 18 | ) 19 | 20 | 21 | def test_chunk_size_attributes(): 22 | assert hasattr(ChunkSize, "KB") 23 | assert hasattr(ChunkSize, "MB") 24 | assert getattr(ChunkSize, "KB") == ChunkSizeKB 25 | assert getattr(ChunkSize, "MB") == ChunkSizeMB 26 | 27 | 28 | def test_chunk_size_valid_input(): 29 | for x in [1, 2, 4, 8, 16, 32, 64, 128, 256]: 30 | assert ChunkSize.KB(t.cast(KBLiteral, x)).value == 1024 * x 31 | for x in range(0, 10): 32 | x += 1 33 | assert ChunkSize.MB(t.cast(MBLiteral, x)).value == 1024**2 * x 34 | 35 | 36 | def test_chunk_size_invalid_input(): 37 | with pytest.raises(errors.InvalidUsageError): 38 | ChunkSize() 39 | 40 | with pytest.raises(ValidationError): 41 | ChunkSize.KB(t.cast(KBLiteral, -1)) 42 | with pytest.raises(ValidationError): 43 | ChunkSize.KB(t.cast(KBLiteral, 0)) 44 | 45 | with pytest.raises(ValidationError): 46 | ChunkSize.MB(t.cast(MBLiteral, -1)) 47 | with pytest.raises(ValidationError): 48 | ChunkSize.MB(t.cast(MBLiteral, 0)) 49 | 50 | 51 | def test_determine_file_chunk_size(): 52 | kilo_bytes = 1024 53 | mega_bytes = kilo_bytes * 1024 54 | 55 | for x, y in [(4, 1), (16, 4), (64, 16), (256, 64), (1024, 256)]: 56 | _cs = ChunkSize.determine_from_data_size(kilo_bytes * x) 57 | assert _cs.value == kilo_bytes * y 58 | 59 | for x in range(0, 10): 60 | x += 1 61 | _cs = ChunkSize.determine_from_data_size(mega_bytes * x * 100) 62 | assert _cs.value == mega_bytes * x 63 | 64 | _cs = ChunkSize.determine_from_data_size(mega_bytes * 11 * 100) 65 | assert _cs.value == mega_bytes * 10 66 | -------------------------------------------------------------------------------- /quantcrypt/internal/cli/commands/info.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | import site 13 | from typer import Typer 14 | from dotmap import DotMap 15 | from pathlib import Path 16 | from quantcrypt.internal.cli import console 17 | 18 | 19 | info_app = Typer( 20 | name="info", invoke_without_command=True, 21 | help="Prints project info to the console and exits." 22 | ) 23 | 24 | 25 | @info_app.callback() 26 | def command_info() -> None: 27 | title_color = "[{}]".format("#ff5fff") 28 | key_color = "[{}]".format("#87d7d7") 29 | value_color = "[{}]".format("#ffd787") 30 | 31 | console.styled_print(f"{title_color}Package Info:") 32 | for k, v in PackageInfo().toDict().items(): 33 | k = f"{key_color}{k}" 34 | v = f"{value_color}{v}" 35 | console.styled_print(f"{2 * ' '}{k}: {v}") 36 | console.styled_print('') 37 | 38 | 39 | class PackageInfo(DotMap): 40 | _PACKAGE_NAME = "quantcrypt" 41 | 42 | def __init__(self) -> None: 43 | super().__init__() 44 | 45 | for site_dir in site.getsitepackages(): 46 | if "site-packages" not in site_dir: # pragma: no cover 47 | continue 48 | 49 | for child in Path(site_dir).iterdir(): 50 | is_self_pkg = child.name.startswith(self._PACKAGE_NAME) 51 | if not is_self_pkg or child.suffix != '.dist-info': 52 | continue 53 | 54 | meta = child / 'METADATA' 55 | with meta.open("r") as file: 56 | lines = file.readlines() 57 | self._set_fields(lines) 58 | 59 | def _set_fields(self, lines: list[str]) -> None: 60 | for line in lines: # pragma: no branch 61 | if line.startswith("\n"): 62 | break 63 | k, v = line.split(": ", maxsplit=1) 64 | if k in ["Name", "Version", "Summary"]: 65 | setattr(self, k, v.rstrip()) 66 | elif k == "License-Expression": 67 | setattr(self, "License", v.rstrip()) 68 | elif k == "Author-email": 69 | setattr(self, "Author", v.rstrip()) 70 | elif v.startswith("Repository"): 71 | setattr(self, "Homepage", v.split(', ')[1].rstrip()) 72 | -------------------------------------------------------------------------------- /tests/test_cli/test_enc_dec.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | from collections.abc import Callable 13 | from quantcrypt.internal.pqa import kem_algos as kem 14 | from .conftest import CryptoFilePaths, CLIMessages 15 | 16 | 17 | def test_mlkem_512(cfp_setup, cli_runner): 18 | with cfp_setup(kem.MLKEM_512) as cfp: 19 | run_tests(cfp, cli_runner) 20 | 21 | 22 | def test_mlkem_768(cfp_setup, cli_runner): 23 | with cfp_setup(kem.MLKEM_768) as cfp: 24 | run_tests(cfp, cli_runner) 25 | 26 | 27 | def test_mlkem_1024(cfp_setup, cli_runner): 28 | with cfp_setup(kem.MLKEM_1024) as cfp: 29 | run_tests(cfp, cli_runner) 30 | 31 | 32 | def run_tests(cfp: CryptoFilePaths, cli_runner: Callable): 33 | algo = cfp.algorithm 34 | print(f"Testing {algo} encryption and decryption in CLI") 35 | 36 | enc_opt = [ 37 | '-p', cfp.public_key_fp, 38 | '-i', cfp.plaintext_fp, 39 | '-o', cfp.ciphertext_fp 40 | ] 41 | dec_opt = [ 42 | '-s', cfp.secret_key_fp, 43 | '-i', cfp.ciphertext_fp, 44 | '-o', cfp.plaintext_fp + '2' 45 | ] 46 | cli_runner("keygen", ['-N', algo]) 47 | 48 | cli_runner("encrypt", enc_opt, "n\n", CLIMessages.CANCELLED) 49 | cli_runner("encrypt", enc_opt + ['-D'], "y\n", CLIMessages.DRYRUN) 50 | cli_runner("encrypt", enc_opt, "y\n", CLIMessages.SUCCESS) 51 | cli_runner("encrypt", enc_opt, "y\nn\n", CLIMessages.CANCELLED) 52 | cli_runner("encrypt", enc_opt, "y\ny\n", CLIMessages.SUCCESS) 53 | cli_runner("encrypt", enc_opt + ['-N'], "", CLIMessages.ERROR) 54 | cli_runner("encrypt", enc_opt + ['-N', '-W'], "", CLIMessages.SUCCESS) 55 | 56 | cli_runner("decrypt", dec_opt, "n\n", CLIMessages.CANCELLED) 57 | cli_runner("decrypt", dec_opt + ['-D'], "y\n", CLIMessages.DRYRUN) 58 | cli_runner("decrypt", dec_opt, "y\n", CLIMessages.SUCCESS) 59 | cli_runner("decrypt", dec_opt, "y\nn\n", CLIMessages.CANCELLED) 60 | cli_runner("decrypt", dec_opt, "y\ny\n", CLIMessages.SUCCESS) 61 | cli_runner("decrypt", dec_opt + ['-N'], "", CLIMessages.ERROR) 62 | cli_runner("decrypt", dec_opt + ['-N', '-W'], "", CLIMessages.SUCCESS) 63 | 64 | with open(cfp.plaintext_fp + '2', 'rb') as file: 65 | assert cfp.ptf_data == file.read() 66 | -------------------------------------------------------------------------------- /tests/test_cli/test_sign_verify.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | from typing import Callable 13 | from quantcrypt.internal.pqa import dss_algos as dss 14 | from .conftest import CryptoFilePaths, CLIMessages 15 | 16 | 17 | def test_mldsa_44(cfp_setup, cli_runner): 18 | with cfp_setup(dss.MLDSA_44) as cfp: 19 | run_tests(cfp, cli_runner) 20 | 21 | 22 | def test_mldsa_65(cfp_setup, cli_runner): 23 | with cfp_setup(dss.MLDSA_65) as cfp: 24 | run_tests(cfp, cli_runner) 25 | 26 | 27 | def test_mldsa_87(cfp_setup, cli_runner): 28 | with cfp_setup(dss.MLDSA_87) as cfp: 29 | run_tests(cfp, cli_runner) 30 | 31 | 32 | def test_falcon_512(cfp_setup, cli_runner): 33 | with cfp_setup(dss.FALCON_512) as cfp: 34 | run_tests(cfp, cli_runner) 35 | 36 | 37 | def test_falcon_1024(cfp_setup, cli_runner): 38 | with cfp_setup(dss.FALCON_1024) as cfp: 39 | run_tests(cfp, cli_runner) 40 | 41 | 42 | def test_small_sphincs(cfp_setup, cli_runner): 43 | with cfp_setup(dss.SMALL_SPHINCS) as cfp: 44 | run_tests(cfp, cli_runner) 45 | 46 | 47 | def test_fast_sphincs(cfp_setup, cli_runner): 48 | with cfp_setup(dss.FAST_SPHINCS) as cfp: 49 | run_tests(cfp, cli_runner) 50 | 51 | 52 | def run_tests(cfp: CryptoFilePaths, cli_runner: Callable): 53 | algo = cfp.algorithm 54 | print(f"Testing {algo} signature verification in CLI") 55 | 56 | sign_opt = [ 57 | '-s', cfp.secret_key_fp, 58 | '-i', cfp.plaintext_fp, 59 | '-S', cfp.signature_fp 60 | ] 61 | verify_opt = [ 62 | '-p', cfp.public_key_fp, 63 | '-i', cfp.plaintext_fp, 64 | '-S', cfp.signature_fp 65 | ] 66 | cli_runner("keygen", ['-N', algo]) 67 | 68 | cli_runner("sign", sign_opt, "n\n", CLIMessages.CANCELLED) 69 | cli_runner("sign", sign_opt + ['-D'], "y\n", CLIMessages.DRYRUN) 70 | cli_runner("sign", sign_opt, "y\n", CLIMessages.SUCCESS) 71 | cli_runner("sign", sign_opt, "y\nn\n", CLIMessages.CANCELLED) 72 | cli_runner("sign", sign_opt, "y\ny\n", CLIMessages.SUCCESS) 73 | cli_runner("sign", sign_opt + ['-N'], "", CLIMessages.ERROR) 74 | cli_runner("sign", sign_opt + ['-N', '-W'], "", CLIMessages.SUCCESS) 75 | 76 | cli_runner("verify", verify_opt, "n\n", CLIMessages.CANCELLED) 77 | cli_runner("verify", verify_opt + ['-D'], "y\n", CLIMessages.DRYRUN) 78 | cli_runner("verify", verify_opt, "y\n", CLIMessages.SUCCESS) 79 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | import string 13 | import pytest 14 | import secrets 15 | from pathlib import Path 16 | from pydantic import fields 17 | from typing import cast, Callable 18 | from annotated_types import MinLen, MaxLen 19 | from quantcrypt.internal import constants as const 20 | from quantcrypt.internal import errors, utils 21 | 22 | 23 | def test_b64(): 24 | assert utils.b64(b'abcdefg') == "YWJjZGVmZw==" 25 | assert utils.b64("YWJjZGVmZw==") == b'abcdefg' 26 | with pytest.raises(errors.InvalidArgsError): 27 | utils.b64(cast(13, bytes)) 28 | with pytest.raises(errors.InvalidArgsError): 29 | utils.b64("YWJjZGVmZw=") 30 | 31 | 32 | def test_b64pickle(): 33 | b64charset = string.ascii_letters + string.digits + "+/=" 34 | complex_object = const.PQAVariant.members() 35 | jar_str = utils.b64pickle(complex_object) 36 | de_lid = utils.b64pickle(jar_str) 37 | 38 | assert isinstance(jar_str, str) 39 | assert all(c in b64charset for c in jar_str) 40 | assert de_lid == complex_object 41 | 42 | 43 | def test_input_validator(): 44 | decorator = utils.input_validator() 45 | assert isinstance(decorator, Callable) 46 | 47 | 48 | def test_search_upwards(): 49 | path = utils.search_upwards("tests") 50 | assert path == Path(__file__).resolve().parent 51 | assert isinstance(path, Path) 52 | 53 | bad_path = secrets.token_hex() 54 | with pytest.raises(RuntimeError): 55 | utils.search_upwards(bad_path, __file__) 56 | with pytest.raises(RuntimeError): 57 | utils.search_upwards(bad_path, path.parent) 58 | 59 | 60 | def test_annotated_bytes(): 61 | typedef = utils.annotated_bytes(12, 34) 62 | info: fields.FieldInfo = vars(typedef)["__metadata__"][0] 63 | for cls in [MinLen, MaxLen]: 64 | assert any([isinstance(item, cls) for item in info.metadata]) 65 | 66 | 67 | def test_sha3_digest_file(tmp_path: Path): 68 | file_path = tmp_path / "sample.txt" 69 | file_path.write_text("x" * 1024**2) 70 | counter = [] 71 | 72 | utils.sha3_digest_file(file_path) 73 | assert len(counter) == 0 74 | digest = utils.sha3_digest_file( 75 | file_path, 76 | lambda: counter.append(1) 77 | ) 78 | assert isinstance(digest, bytes) 79 | assert len(digest) == 64 80 | assert len(counter) == 4 81 | assert utils.b64(digest).startswith("iWP48uUEEjzU5gXKK8FpzC10Bs") 82 | 83 | 84 | def test_resolve_relpath(): 85 | res = utils.resolve_relpath() 86 | assert res == Path.cwd() 87 | 88 | res = utils.resolve_relpath("asdfg") 89 | assert res == Path.cwd() / "asdfg" 90 | 91 | res = utils.resolve_relpath(Path.cwd() / "qwerty") 92 | assert res == Path.cwd() / "qwerty" 93 | -------------------------------------------------------------------------------- /quantcrypt/internal/cli/commands/keygen.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | import string 13 | from typer import Typer 14 | from quantcrypt.internal.cli import tools, console, annotations as ats 15 | 16 | 17 | keygen_app = Typer( 18 | name="keygen", invoke_without_command=True, no_args_is_help=True, 19 | help="Generates an ASCII armored keypair using a KEM or a DSS algorithm." 20 | ) 21 | 22 | 23 | @keygen_app.callback() 24 | def command_keygen( 25 | algorithm: ats.KeygenAlgo, 26 | identifier: ats.Identifier = None, 27 | directory: ats.Directory = None, 28 | dry_run: ats.DryRun = False, 29 | overwrite: ats.Overwrite = False, 30 | non_interactive: ats.NonInteractive = False 31 | ) -> None: 32 | algorithm = algorithm.lower() 33 | console.notify_dry_run(dry_run) 34 | 35 | prefix = '' 36 | if identifier: 37 | _validate_identifier(identifier) 38 | prefix = f"{identifier}-" 39 | 40 | pqa_cls = tools.get_pqa_class(algorithm) 41 | apk_name = f"{prefix}{algorithm}-pubkey.qc" 42 | ask_name = f"{prefix}{algorithm}-seckey.qc" 43 | 44 | target_dir = tools.resolve_directory(directory) 45 | apk_file = target_dir / apk_name 46 | ask_file = target_dir / ask_name 47 | 48 | a, b = [f"[italic sky_blue2]{x.name}[/]" for x in [apk_file, ask_file]] 49 | console.styled_print( 50 | f"QuantCrypt is about to generate {a} and {b} files\n" 51 | f"into the following directory: [italic tan]{target_dir}\n" 52 | ) 53 | if not non_interactive: 54 | console.ask_continue(exit_on_false=True) 55 | 56 | if apk_file.is_file() or ask_file.is_file(): 57 | console.ask_overwrite_files( 58 | non_interactive, overwrite, 59 | exit_on_false=True 60 | ) 61 | 62 | pqa = pqa_cls() 63 | public_key, secret_key = pqa.keygen() 64 | apk = pqa.armor(public_key) 65 | ask = pqa.armor(secret_key) 66 | 67 | if dry_run: 68 | console.styled_print("QuantCrypt would have created the following files:") 69 | console.pretty_print([apk_file.as_posix(), ask_file.as_posix()]) 70 | else: 71 | apk_file.write_text(apk) 72 | ask_file.write_text(ask) 73 | console.print_success() 74 | 75 | 76 | def _validate_identifier(name_arg: str) -> None: 77 | if len(name_arg) > 15: 78 | console.raise_error("Unique identifier cannot be longer than 15 characters!") 79 | allowed_chars = string.ascii_letters + string.digits 80 | for char in name_arg: 81 | if char not in allowed_chars: 82 | console.raise_error( 83 | "Only characters [[chartreuse3]a-z, A-Z,[/] " 84 | "0-9] are allowed in the unique identifier!" 85 | ) 86 | -------------------------------------------------------------------------------- /quantcrypt/internal/cli/console.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | from typing import Any 13 | from rich.prompt import Confirm 14 | from rich.console import Console 15 | from rich.pretty import pprint 16 | 17 | 18 | __all__ = [ 19 | "pretty_print", 20 | "styled_print", 21 | "print_success", 22 | "print_cancelled", 23 | "raise_error", 24 | "ask_continue", 25 | "ask_overwrite_files", 26 | "notify_dry_run" 27 | ] 28 | 29 | 30 | def _with_styled_name(message: str) -> str: 31 | sub = "[bold hot_pink3]QuantCrypt[/]" 32 | return message.replace("QuantCrypt", sub) 33 | 34 | 35 | def _custom_print(message: str, color: str | None, end: str) -> None: 36 | console = Console(soft_wrap=True) 37 | if isinstance(color, str): 38 | message = f"[{color}]{message}[/]" 39 | console.print(message, end=end) 40 | 41 | 42 | def pretty_print(message: Any) -> None: 43 | pprint(message, expand_all=True) 44 | 45 | 46 | def styled_print(message: str, end='\n') -> None: 47 | msg = _with_styled_name(message) 48 | _custom_print(msg, color=None, end=end) 49 | 50 | 51 | def print_success(end='\n\n') -> None: 52 | msg = " :heavy_check_mark: - Operation successful!" 53 | _custom_print(msg, color="chartreuse3", end=end) 54 | 55 | 56 | def print_cancelled(end='\n\n') -> None: 57 | msg = " :warning: - Operation cancelled." 58 | _custom_print(msg, color="gold3", end=end) 59 | 60 | 61 | def raise_error(reason: str, end='\n\n') -> None: 62 | msg = ":cross_mark: - QuantCrypt Error:" 63 | _custom_print(msg, color="bold bright_red", end='\n') 64 | _custom_print(reason, color=None, end=end) 65 | raise SystemExit(1) 66 | 67 | 68 | def ask_continue(exit_on_false: bool = False) -> bool: 69 | answer = Confirm.ask("Do you want to continue?") 70 | if exit_on_false is True and answer is False: 71 | print_cancelled() 72 | raise SystemExit(0) 73 | return answer 74 | 75 | 76 | def ask_overwrite_files(non_interactive: bool, overwrite: bool, exit_on_false: bool) -> bool: 77 | if non_interactive and not overwrite: 78 | raise_error( 79 | "Must explicitly enable file overwriting with " 80 | "the [bold turquoise2]--overwrite[/] option in " 81 | "non-interactive mode." 82 | ) 83 | elif not overwrite: 84 | answer = Confirm.ask("Okay to overwrite existing files?") 85 | if exit_on_false is True and answer is False: 86 | print_cancelled() 87 | raise SystemExit(0) 88 | return answer 89 | 90 | 91 | def notify_dry_run(dry_run: bool, end='\n') -> None: 92 | if dry_run: 93 | msg = ":warning: DRY RUN MODE :warning:" 94 | _custom_print(msg, color="bold dark_orange", end=end) 95 | -------------------------------------------------------------------------------- /quantcrypt/internal/cli/commands/compile.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | import platform 13 | from typer import Typer 14 | from quantcrypt.internal import compiler, constants as const 15 | from quantcrypt.internal.cli import console, annotations as ats 16 | 17 | 18 | compile_app = Typer( 19 | name="compile", invoke_without_command=True, help=' '.join([ 20 | "Compiles PQA binaries from PQClean C source code using CFFI.", 21 | "Requires an active internet connection and pre-installed platform-specific build tools.", 22 | "Calling this command without options begins the compilation process.", 23 | "Use the --help option to see all available options." 24 | ]) 25 | ) 26 | 27 | 28 | @compile_app.callback() 29 | def command_compile( 30 | algorithms: ats.CompileAlgos = None, 31 | with_opt: ats.WithOpt = None, 32 | dry_run: ats.DryRun = False, 33 | non_interactive: ats.NonInteractive = False 34 | ) -> None: 35 | console.notify_dry_run(dry_run) 36 | 37 | algos = const.SupportedAlgos 38 | if algorithms: 39 | algos = const.SupportedAlgos.filter(algorithms) 40 | 41 | variants = [const.PQAVariant.REF] 42 | if with_opt: # pragma: no cover 43 | arch = platform.machine().lower() 44 | if arch in const.AMDArches: 45 | variants.append(const.PQAVariant.OPT_AMD) 46 | elif arch in const.ARMArches: 47 | variants.append(const.PQAVariant.OPT_ARM) 48 | else: 49 | console.raise_error("This machine does not support optimized variants.") 50 | 51 | variants_fmt = 'only the [italic tan]clean[/]' 52 | if len(variants) > 1: 53 | variants_fmt = ' and '.join(f"[italic tan]{v.value}[/]" for v in variants) 54 | 55 | console.styled_print( 56 | f"QuantCrypt is about to compile {variants_fmt} " 57 | f"variants of [bold sky_blue2]PQC algorithms[/]." 58 | ) 59 | if not non_interactive: 60 | console.ask_continue(exit_on_false=True) 61 | 62 | if dry_run: 63 | console.styled_print("QuantCrypt would have compiled the following algorithms:") 64 | console.pretty_print(', '.join(s.armor_name() for s in algos)) 65 | return 66 | 67 | console.styled_print("\nInitializing compilation[grey46]...[/]\n") 68 | process = compiler.Compiler.run(variants, algos, in_subprocess=True) 69 | 70 | for line in process.stdout: # type: str 71 | if line.startswith(const.SubprocTag): 72 | line = line.lstrip(const.SubprocTag) 73 | console.styled_print(line.rstrip()) 74 | 75 | process.wait() 76 | print() 77 | if process.returncode == 0: 78 | console.print_success() 79 | else: 80 | console.raise_error("Failed to compile PQC algorithms.") 81 | -------------------------------------------------------------------------------- /quantcrypt/internal/chunksize.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | import typing as t 13 | from pydantic import Field, validate_call 14 | from dataclasses import dataclass 15 | from quantcrypt.internal import errors 16 | 17 | 18 | __all__ = ["KBLiteral", "MBLiteral", "ChunkSizeKB", "ChunkSizeMB", "ChunkSize"] 19 | 20 | 21 | KBLiteral = t.Literal[1, 2, 4, 8, 16, 32, 64, 128, 256] 22 | MBLiteral = t.Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10] 23 | 24 | 25 | @dataclass(frozen=True) 26 | class ChunkSizeKB: 27 | value: int 28 | 29 | @validate_call 30 | def __init__(self, size: KBLiteral) -> None: 31 | """ 32 | :param size: The chunk size in kilobytes. 33 | :raises - pydantic.ValidationError: 34 | On invalid size argument value. 35 | """ 36 | object.__setattr__(self, 'value', 1024 * size) 37 | 38 | 39 | @dataclass(frozen=True) 40 | class ChunkSizeMB: 41 | value: int 42 | 43 | @validate_call 44 | def __init__(self, size: MBLiteral) -> None: 45 | """ 46 | :param size: The chunk size in megabytes. 47 | :raises - pydantic.ValidationError: 48 | On invalid size argument value. 49 | """ 50 | object.__setattr__(self, 'value', 1024 ** 2 * size) 51 | 52 | 53 | class ChunkSize: 54 | atd = t.Annotated[ 55 | t.Optional[ChunkSizeKB | ChunkSizeMB], 56 | Field(default=None) 57 | ] 58 | 59 | def __init__(self): 60 | """ 61 | This class is a collection of classes and is not 62 | intended to be instantiated directly. You can access 63 | the contained **KB** and **MB** classes as attributes 64 | of this class. 65 | """ 66 | raise errors.InvalidUsageError( 67 | "ChunkSize class is a collection of classes and " 68 | "is not intended to be instantiated directly." 69 | ) 70 | KB: t.Type[ChunkSizeKB] = ChunkSizeKB 71 | MB: t.Type[ChunkSizeMB] = ChunkSizeMB 72 | 73 | @staticmethod 74 | def determine_from_data_size(data_size: int) -> ChunkSizeKB | ChunkSizeMB: 75 | kilo_bytes = 1024 76 | mega_bytes = kilo_bytes * 1024 77 | 78 | if data_size <= kilo_bytes * 4: 79 | return ChunkSizeKB(1) 80 | elif data_size <= kilo_bytes * 16: 81 | return ChunkSizeKB(4) 82 | elif data_size <= kilo_bytes * 64: 83 | return ChunkSizeKB(16) 84 | elif data_size <= kilo_bytes * 256: 85 | return ChunkSizeKB(64) 86 | elif data_size <= kilo_bytes * 1024: 87 | return ChunkSizeKB(256) 88 | 89 | for x in range(0, 10): 90 | x += 1 91 | if data_size <= mega_bytes * x * 100: 92 | return ChunkSizeMB(t.cast(MBLiteral, x)) 93 | return ChunkSizeMB(10) 94 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Must allow pqclean binaries 10 | !quantcrypt/internal/bin/**/*.pyd 11 | !quantcrypt/internal/bin/**/*.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .htmlcov 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | *.py,cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | cover/ 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | db.sqlite3 67 | db.sqlite3-journal 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | .pybuilder/ 81 | target/ 82 | 83 | # Jupyter Notebook 84 | .ipynb_checkpoints 85 | 86 | # IPython 87 | profile_default/ 88 | ipython_config.py 89 | 90 | # pdm 91 | .pdm.toml 92 | 93 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 94 | __pypackages__/ 95 | 96 | # Celery stuff 97 | celerybeat-schedule 98 | celerybeat.pid 99 | 100 | # SageMath parsed files 101 | *.sage.py 102 | 103 | # Environments 104 | .env 105 | .venv 106 | env/ 107 | venv/ 108 | ENV/ 109 | env.bak/ 110 | venv.bak/ 111 | 112 | # Spyder project settings 113 | .spyderproject 114 | .spyproject 115 | 116 | # Rope project settings 117 | .ropeproject 118 | 119 | # mkdocs documentation 120 | /site 121 | 122 | # mypy 123 | .mypy_cache/ 124 | .dmypy.json 125 | dmypy.json 126 | 127 | # Pyre type checker 128 | .pyre/ 129 | 130 | # pytype static type analyzer 131 | .pytype/ 132 | 133 | # Cython debug symbols 134 | cython_debug/ 135 | 136 | # CMake 137 | cmake-build-*/ 138 | 139 | # File-based project format 140 | *.iws 141 | 142 | # IntelliJ 143 | out/ 144 | 145 | # mpeltonen/sbt-idea plugin 146 | .idea_modules/ 147 | 148 | # JIRA plugin 149 | atlassian-ide-plugin.xml 150 | 151 | # Crashlytics plugin (for Android Studio and IntelliJ) 152 | com_crashlytics_export_strings.xml 153 | crashlytics.properties 154 | crashlytics-build.properties 155 | fabric.properties 156 | 157 | # PyCharm IDE 158 | .idea/ 159 | 160 | # General exclude folder 161 | .exclude 162 | 163 | # PQClean source code 164 | quantcrypt/pqclean/common 165 | quantcrypt/pqclean/crypto_kem 166 | quantcrypt/pqclean/crypto_sign 167 | 168 | # Compiled PQClean binaries 169 | quantcrypt/internal/bin/ 170 | -------------------------------------------------------------------------------- /quantcrypt/internal/kdf/kmac_kdf.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | import struct 13 | from pydantic import Field 14 | from typing import Annotated, Optional 15 | from Cryptodome.Hash import KMAC256 16 | from quantcrypt.internal import errors 17 | from quantcrypt.internal import utils 18 | 19 | 20 | __all__ = ["KKDF"] 21 | 22 | 23 | class KKDF: 24 | @utils.input_validator() 25 | def __new__( 26 | cls, 27 | master: Annotated[bytes, Field(min_length=32)], 28 | key_len: Annotated[int, Field(ge=32, le=1024)] = 32, 29 | num_keys: Annotated[int, Field(ge=1, le=2048)] = 1, 30 | salt: Annotated[Optional[bytes], Field()] = None, 31 | context: Annotated[Optional[bytes], Field()] = None 32 | ) -> tuple[bytes, ...]: 33 | """ 34 | KKDF is a variant of HKDF, where the pseudorandom function 35 | HMAC is replaced with KMAC256, which is based on cSHAKE256, 36 | as defined in NIST SP 800-185. This implementation of KKDF 37 | only allows to derive 65536 bytes of keys from one master 38 | key, which is calculated with `key_len` * `num_keys`. 39 | 40 | :param master: The master secret key from which to derive new keys. 41 | :param key_len: The length of each derivative key in bytes. 42 | :param num_keys: The number of derivative keys to generate. 43 | :param salt: A cryptographically strong random number. It is 44 | recommended to generate a unique salt for each master key. 45 | :param context: A customization string, behaves like a namespace. 46 | Can be reused across multiple master keys and salt values. 47 | :raises - errors.KDFOutputLimitError: When `key_len` * `num_keys` 48 | is larger than the allowed maximum output of 65536 bytes for 49 | one master key. 50 | """ 51 | digest_size = 64 52 | entropy_limit = digest_size * 1024 53 | output_len = key_len * num_keys 54 | 55 | if output_len > entropy_limit: 56 | raise errors.KDFOutputLimitError(output_len) 57 | if salt is None: 58 | salt = b'\x00' * digest_size 59 | if context is None: 60 | context = b'' 61 | 62 | # Step 1: extract 63 | prk = KMAC256.new( 64 | key=master, 65 | data=salt, 66 | mac_len=digest_size, 67 | custom=b'' 68 | ).digest() 69 | 70 | # Step 2: expand 71 | macs = b'' 72 | iters = 1 73 | while len(macs) < output_len: 74 | iter_byte = struct.pack('H', iters) 75 | macs += KMAC256.new( 76 | key=prk, 77 | data=macs[-digest_size:] + iter_byte, 78 | mac_len=digest_size, 79 | custom=context 80 | ).digest() 81 | iters += 1 82 | 83 | # Step 3: return 84 | out: list[bytes] = [ 85 | macs[idx:idx + key_len] 86 | for idx in range(0, output_len, key_len) 87 | ] 88 | return tuple(out) 89 | -------------------------------------------------------------------------------- /quantcrypt/internal/kdf/common.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | from dotmap import DotMap 13 | from pydantic import Field 14 | from typing import Type, Annotated, Literal 15 | from quantcrypt.internal import errors 16 | from quantcrypt.internal import utils 17 | 18 | 19 | __all__ = ["MemCostMB", "MemCostGB", "MemCost", "KDFParams"] 20 | 21 | 22 | class MemCostMB(dict): 23 | @utils.input_validator() 24 | def __init__(self, size: Literal[32, 64, 128, 256, 512]) -> None: 25 | """ 26 | Converts the size input argument value of megabytes to kilobytes. 27 | 28 | :param size: The memory cost size in megabytes 29 | :return: The memory cost in kilobytes 30 | :raises - pydantic.ValidationError: 31 | If the input size value is not a valid Literal 32 | """ 33 | super().__init__(value=1024 * size) 34 | 35 | 36 | class MemCostGB(dict): 37 | @utils.input_validator() 38 | def __init__(self, size: Literal[1, 2, 3, 4, 5, 6, 7, 8]) -> None: 39 | """ 40 | Converts the size input argument value of gigabytes to kilobytes. 41 | 42 | :param size: The memory cost size in gigabytes 43 | :return: The memory cost in kilobytes 44 | :raises - pydantic.ValidationError: 45 | If the input size value is not a valid Literal 46 | """ 47 | super().__init__(value=1024 ** 2 * size) 48 | 49 | 50 | class MemCost: 51 | def __init__(self): 52 | """ 53 | This class is a collection of classes and is not 54 | intended to be instantiated directly. You can access 55 | the contained **MB** and **GB** classes as attributes 56 | of this class. 57 | """ 58 | raise errors.InvalidUsageError( 59 | "MemCost class is a collection of classes and " 60 | "is not intended to be instantiated directly." 61 | ) 62 | MB: Type[MemCostMB] = MemCostMB 63 | GB: Type[MemCostGB] = MemCostGB 64 | 65 | 66 | class KDFParams(DotMap): 67 | @utils.input_validator() 68 | def __init__( 69 | self, 70 | memory_cost: MemCostMB | MemCostGB, 71 | parallelism: Annotated[int, Field(gt=0)], 72 | time_cost: Annotated[int, Field(gt=0)], 73 | hash_len: Annotated[int, Field(ge=16, le=64)] = 32, 74 | salt_len: Annotated[int, Field(ge=16, le=64)] = 32 75 | ) -> None: 76 | """ 77 | Custom parameters for altering the security 78 | level of key derivation functions. 79 | 80 | :param memory_cost: The amount of memory the KDF must use. 81 | :param parallelism: Up to how many threads the KDF can use. 82 | :param time_cost: The amount of iterations the KDF must run. 83 | :param hash_len: The length of the generated hash, in bytes. 84 | :param salt_len: The length of the generated salt, in bytes. 85 | """ 86 | memory_cost = memory_cost.get("value") 87 | super().__init__({ 88 | k: v for k, v in locals().items() 89 | if k not in ["self", "__class__"] 90 | }) 91 | -------------------------------------------------------------------------------- /tests/test_cli/test_keygen.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | from pathlib import Path 13 | from collections.abc import Callable 14 | from quantcrypt.internal import utils 15 | from quantcrypt.internal.pqa import dss_algos as dss 16 | from quantcrypt.internal.pqa import kem_algos as kem 17 | from .conftest import CryptoFilePaths, CLIMessages 18 | 19 | 20 | def test_mlkem_512(cfp_setup, cli_runner): 21 | with cfp_setup(kem.MLKEM_512) as cfp: 22 | run_tests(cfp, cli_runner) 23 | 24 | 25 | def test_mlkem_768(cfp_setup, cli_runner): 26 | with cfp_setup(kem.MLKEM_768) as cfp: 27 | run_tests(cfp, cli_runner) 28 | 29 | 30 | def test_mlkem_1024(cfp_setup, cli_runner): 31 | with cfp_setup(kem.MLKEM_1024) as cfp: 32 | run_tests(cfp, cli_runner) 33 | 34 | 35 | def test_mldsa_44(cfp_setup, cli_runner): 36 | with cfp_setup(dss.MLDSA_44) as cfp: 37 | run_tests(cfp, cli_runner) 38 | 39 | 40 | def test_mldsa_65(cfp_setup, cli_runner): 41 | with cfp_setup(dss.MLDSA_65) as cfp: 42 | run_tests(cfp, cli_runner) 43 | 44 | 45 | def test_mldsa_87(cfp_setup, cli_runner): 46 | with cfp_setup(dss.MLDSA_87) as cfp: 47 | run_tests(cfp, cli_runner) 48 | 49 | 50 | def test_falcon_512(cfp_setup, cli_runner): 51 | with cfp_setup(dss.FALCON_512) as cfp: 52 | run_tests(cfp, cli_runner) 53 | 54 | 55 | def test_falcon_1024(cfp_setup, cli_runner): 56 | with cfp_setup(dss.FALCON_1024) as cfp: 57 | run_tests(cfp, cli_runner) 58 | 59 | 60 | def test_small_sphincs(cfp_setup, cli_runner): 61 | with cfp_setup(dss.SMALL_SPHINCS) as cfp: 62 | run_tests(cfp, cli_runner) 63 | 64 | 65 | def test_fast_sphincs(cfp_setup, cli_runner): 66 | with cfp_setup(dss.FAST_SPHINCS) as cfp: 67 | run_tests(cfp, cli_runner) 68 | 69 | 70 | def run_tests(cfp: CryptoFilePaths, cli_runner: Callable): 71 | algo = cfp.algorithm 72 | print(f"Testing {algo} key generation in CLI") 73 | 74 | public_key = Path(cfp.public_key_fp) 75 | secret_key = Path(cfp.secret_key_fp) 76 | 77 | assert not public_key.exists() 78 | assert not secret_key.exists() 79 | 80 | cli_runner("keygen", [algo], "n\n", CLIMessages.CANCELLED) 81 | cli_runner("keygen", ["-D", algo], "y\n", CLIMessages.DRYRUN) 82 | 83 | assert not public_key.exists() 84 | assert not secret_key.exists() 85 | 86 | cli_runner("keygen", [algo], "y\n", CLIMessages.SUCCESS) 87 | 88 | assert public_key.exists() 89 | assert secret_key.exists() 90 | 91 | pkf_digest_1 = utils.sha3_digest_file(public_key) 92 | skf_digest_1 = utils.sha3_digest_file(secret_key) 93 | 94 | cli_runner("keygen", [algo], "y\nn\n", CLIMessages.CANCELLED) 95 | cli_runner("keygen", [algo], "y\ny\n", CLIMessages.SUCCESS) 96 | 97 | cli_runner("keygen", ["-N", algo], "", CLIMessages.ERROR) 98 | cli_runner("keygen", ["-N", "-W", algo], "", CLIMessages.SUCCESS) 99 | 100 | cli_runner("keygen", ["-i", "!", algo], "", CLIMessages.ERROR) 101 | cli_runner("keygen", ["-i", "x" * 16, algo], "", CLIMessages.ERROR) 102 | cli_runner("keygen", ["-i", "asdfg", algo], "y\n", CLIMessages.SUCCESS) 103 | 104 | public_key = public_key.with_name(f"asdfg-{public_key.name}") 105 | secret_key = secret_key.with_name(f"asdfg-{secret_key.name}") 106 | 107 | pkf_digest_2 = utils.sha3_digest_file(public_key) 108 | skf_digest_2 = utils.sha3_digest_file(secret_key) 109 | 110 | assert pkf_digest_1 != pkf_digest_2 111 | assert skf_digest_1 != skf_digest_2 112 | -------------------------------------------------------------------------------- /quantcrypt/internal/utils.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | import pickle 13 | import base64 14 | import binascii 15 | import typing as t 16 | from pathlib import Path 17 | from functools import cache 18 | from Cryptodome.Hash import SHA3_512 19 | from pydantic import Field, ConfigDict, validate_call 20 | from quantcrypt.internal.chunksize import ChunkSize 21 | from quantcrypt.internal import errors 22 | 23 | 24 | __all__ = [ 25 | "b64", 26 | "b64pickle", 27 | "input_validator", 28 | "search_upwards", 29 | "annotated_bytes", 30 | "read_file_chunks", 31 | "sha3_digest_file", 32 | "resolve_relpath" 33 | ] 34 | 35 | 36 | def b64(data: str | bytes) -> str | bytes: 37 | try: 38 | if isinstance(data, str): 39 | return base64.b64decode(data.encode("utf-8")) 40 | elif isinstance(data, bytes): 41 | return base64.b64encode(data).decode("utf-8") 42 | raise errors.InvalidArgsError 43 | except (UnicodeError, binascii.Error): 44 | raise errors.InvalidArgsError 45 | 46 | 47 | T = t.TypeVar("T") 48 | def b64pickle(obj: T | str) -> T | str: 49 | if isinstance(obj, str): 50 | return pickle.loads(b64(obj)) 51 | return b64(pickle.dumps(obj)) 52 | 53 | 54 | def input_validator() -> t.Callable: 55 | return validate_call(config=ConfigDict( 56 | arbitrary_types_allowed=True, 57 | validate_return=True 58 | )) 59 | 60 | 61 | @cache 62 | def search_upwards(for_path: str | Path, from_path: str | Path = __file__) -> Path: 63 | current_path = Path(from_path).parent.resolve() 64 | while current_path != current_path.parent: 65 | search_path = current_path / for_path 66 | if search_path.exists(): 67 | return search_path 68 | elif (current_path / ".git").exists(): 69 | break 70 | current_path = current_path.parent 71 | raise RuntimeError(f"Cannot find path '{for_path}' upwards from '{from_path}'") 72 | 73 | 74 | def annotated_bytes( 75 | min_size: int = None, 76 | max_size: int = None, 77 | equal_to: int = None 78 | ) -> t.Type[bytes]: 79 | return t.Annotated[bytes, Field( 80 | min_length=equal_to or min_size, 81 | max_length=equal_to or max_size, 82 | strict=True 83 | )] 84 | 85 | 86 | def read_file_chunks( 87 | file: t.BinaryIO, 88 | chunk_size: int, 89 | callback: t.Callable | None = None 90 | ) -> t.Generator[bytes, None, None]: 91 | while True: 92 | chunk = file.read(chunk_size) 93 | if not chunk: 94 | break 95 | elif callback: 96 | callback() 97 | yield chunk 98 | 99 | 100 | def sha3_digest_file(file_path: Path, callback: t.Callable | None = None) -> bytes: 101 | sha3 = SHA3_512.new() 102 | file_size = file_path.stat().st_size 103 | chunk_size = ChunkSize.determine_from_data_size(file_size) 104 | 105 | with open(file_path, 'rb') as read_file: 106 | for chunk in read_file_chunks(read_file, chunk_size.value, callback): 107 | sha3.update(chunk) 108 | return sha3.digest() 109 | 110 | 111 | def resolve_relpath(path: str | Path | None = None) -> Path: 112 | _path = Path(path or '') 113 | if _path.is_absolute(): 114 | return Path(_path) 115 | return (Path.cwd() / _path).resolve() 116 | -------------------------------------------------------------------------------- /quantcrypt/internal/pqa/kem_algos.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | from quantcrypt.internal import utils, constants as const 13 | from quantcrypt.internal.pqa.base_kem import BaseKEM 14 | 15 | 16 | __all__ = ["MLKEM_512", "MLKEM_768", "MLKEM_1024"] 17 | 18 | 19 | class MLKEM_512(BaseKEM): # NOSONAR 20 | @utils.input_validator() 21 | def __init__(self, variant: const.PQAVariant = None, *, allow_fallback: bool = True) -> None: 22 | """ 23 | Initializes the MLKEM_512 key encapsulation mechanism algorithm 24 | instance with compiled C extension binaries. 25 | 26 | :param variant: Which compiled binary to use underneath. 27 | When variant is None *(auto-select mode)*, QuantCrypt will first try to use 28 | platform-optimized binaries. If it fails to do so and fallback is allowed, 29 | it will then try to fall back to using clean reference binaries. 30 | :param allow_fallback: Allow falling back to using clean reference binaries when 31 | QuantCrypt has failed to import platform-optimized binaries. Defaults to True. 32 | :raises - ImportFailedError: When QuantCrypt has failed to fall back to using clean 33 | reference binaries, either because they are missing or fallback was not permitted. 34 | """ 35 | super().__init__(variant, allow_fallback) 36 | 37 | 38 | class MLKEM_768(BaseKEM): # NOSONAR 39 | @utils.input_validator() 40 | def __init__(self, variant: const.PQAVariant = None, *, allow_fallback: bool = True) -> None: 41 | """ 42 | Initializes the MLKEM_512 key encapsulation mechanism algorithm 43 | instance with compiled C extension binaries. 44 | 45 | :param variant: Which compiled binary to use underneath. 46 | When variant is None *(auto-select mode)*, QuantCrypt will first try to use 47 | platform-optimized binaries. If it fails to do so and fallback is allowed, 48 | it will then try to fall back to using clean reference binaries. 49 | :param allow_fallback: Allow falling back to using clean reference binaries when 50 | QuantCrypt has failed to import platform-optimized binaries. Defaults to True. 51 | :raises - ImportFailedError: When QuantCrypt has failed to fall back to using clean 52 | reference binaries, either because they are missing or fallback was not permitted. 53 | """ 54 | super().__init__(variant, allow_fallback) 55 | 56 | 57 | class MLKEM_1024(BaseKEM): # NOSONAR 58 | @utils.input_validator() 59 | def __init__(self, variant: const.PQAVariant = None, *, allow_fallback: bool = True) -> None: 60 | """ 61 | Initializes the MLKEM_512 key encapsulation mechanism algorithm 62 | instance with compiled C extension binaries. 63 | 64 | :param variant: Which compiled binary to use underneath. 65 | When variant is None *(auto-select mode)*, QuantCrypt will first try to use 66 | platform-optimized binaries. If it fails to do so and fallback is allowed, 67 | it will then try to fall back to using clean reference binaries. 68 | :param allow_fallback: Allow falling back to using clean reference binaries when 69 | QuantCrypt has failed to import platform-optimized binaries. Defaults to True. 70 | :raises - ImportFailedError: When QuantCrypt has failed to fall back to using clean 71 | reference binaries, either because they are missing or fallback was not permitted. 72 | """ 73 | super().__init__(variant, allow_fallback) 74 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "quantcrypt" 3 | version = "1.0.1" 4 | description = "Cross-platform Python library for Post-Quantum Cryptography using precompiled PQClean binaries" 5 | authors = [ 6 | { name = "Mattias Aabmets", email = "mattias.aabmets@gmail.com" } 7 | ] 8 | license = "MIT" 9 | readme = "README.md" 10 | keywords = ["post-quantum", "crypto", "cryptography", "security", "pqclean"] 11 | classifiers = [ 12 | "Development Status :: 5 - Production/Stable", 13 | "Intended Audience :: Developers", 14 | "License :: OSI Approved :: MIT License", 15 | "Natural Language :: English", 16 | "Operating System :: MacOS", 17 | "Operating System :: POSIX :: Linux", 18 | "Operating System :: Microsoft :: Windows", 19 | "Programming Language :: Python :: 3.10", 20 | "Programming Language :: Python :: 3.11", 21 | "Programming Language :: Python :: 3.12", 22 | "Programming Language :: Python :: 3.13", 23 | "Topic :: Security", 24 | "Topic :: Security :: Cryptography", 25 | "Topic :: Software Development", 26 | "Topic :: Software Development :: Libraries" 27 | ] 28 | requires-python = ">=3.10" 29 | dependencies = [ 30 | "argon2-cffi>=23.1.0", 31 | "dotmap>=1.3.30", 32 | "orjson>=3.10.0", 33 | "pycryptodomex>=3.20.0", 34 | "pydantic>=2.9.0", 35 | "rich>=13.8.0", 36 | "typer>=0.15.0", 37 | "types-zxcvbn>=4.5.0", 38 | "zxcvbn>=4.5.0", 39 | ] 40 | 41 | [project.urls] 42 | "Repository" = "https://github.com/aabmets/quantcrypt" 43 | "Documentation" = "https://github.com/aabmets/quantcrypt/wiki" 44 | "Bug Tracker" = "https://github.com/aabmets/quantcrypt/issues" 45 | 46 | [project.scripts] 47 | qclib = "quantcrypt.internal.cli.main:app" 48 | 49 | [project.optional-dependencies] 50 | compiler = [ 51 | "cffi>=1.17.0", 52 | "pyyaml>=6.0.1", 53 | "requests>=2.31.0", 54 | "setuptools>=70.0.0", 55 | ] 56 | 57 | [dependency-groups] 58 | develop = [ 59 | "cffi>=1.17.0", 60 | "coverage>=7.6.0", 61 | "devtools-cli>=0.14.0", 62 | "hatchling>=1.24.0", 63 | "packaging>=24.2", 64 | "pytest>=8.3.0", 65 | "pytest-cov>=6.0.0", 66 | "pytest-xdist>=3.6.1", 67 | "pyyaml>=6.0.1", 68 | "requests>=2.31.0", 69 | "setuptools>=70.0.0", 70 | "tomli>=2.1.1", 71 | ] 72 | 73 | [tool.pytest.ini_options] 74 | console_output_style = "count" 75 | filterwarnings = ["ignore::DeprecationWarning"] 76 | testpaths = ["tests"] 77 | addopts = [ 78 | "--cov=quantcrypt", 79 | "--cov-report=html", 80 | "--no-cov-on-fail", 81 | "--import-mode=append", 82 | "--numprocesses=auto", 83 | "--maxprocesses=8", 84 | "--dist=worksteal" 85 | ] 86 | pythonpath = [ 87 | ".", 88 | "./quantcrypt", 89 | "./quantcrypt/internal" 90 | ] 91 | 92 | [tool.coverage.run] 93 | branch = true 94 | source = ["quantcrypt"] 95 | 96 | [tool.coverage.report] 97 | fail_under = 90 98 | skip_empty = true 99 | ignore_errors = true 100 | exclude_lines = [ 101 | "pragma: no cover", 102 | "def __repr__", 103 | "raise AssertionError", 104 | "raise NotImplementedError", 105 | "if __name__ == .__main__.:", 106 | "@abstractmethod" 107 | ] 108 | 109 | [tool.coverage.html] 110 | directory = ".htmlcov" 111 | 112 | [build-system] 113 | requires = ["hatchling", "packaging"] 114 | build-backend = "hatchling.build" 115 | 116 | [tool.hatch.build.targets.sdist] 117 | include = ["quantcrypt", "scripts"] 118 | exclude = [ 119 | "quantcrypt/pqclean/common", 120 | "quantcrypt/pqclean/crypto_kem", 121 | "quantcrypt/pqclean/crypto_sign", 122 | ] 123 | artifacts = [ 124 | "quantcrypt/internal/bin/*.so", 125 | "quantcrypt/internal/bin/*.pyd" 126 | ] 127 | 128 | [tool.hatch.build.targets.wheel] 129 | include = ["quantcrypt"] 130 | exclude = ["scripts"] 131 | artifacts = [ 132 | "quantcrypt/internal/bin/*.so", 133 | "quantcrypt/internal/bin/*.pyd" 134 | ] 135 | 136 | [tool.hatch.build.targets.wheel.hooks.custom] 137 | path = "scripts/build.py" 138 | -------------------------------------------------------------------------------- /tests/test_cipher/test_krypton_file.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | import pytest 13 | from pathlib import Path 14 | from dotmap import DotMap 15 | from typing import Callable 16 | from quantcrypt.cipher import KryptonFile, ChunkSize 17 | 18 | 19 | def test_krypton_file_attributes(): 20 | krypton = KryptonFile(b'x' * 64) 21 | 22 | assert hasattr(krypton, "encrypt") 23 | assert hasattr(krypton, "decrypt_to_file") 24 | assert hasattr(krypton, "decrypt_to_memory") 25 | assert hasattr(krypton, "read_file_header") 26 | 27 | assert isinstance(getattr(krypton, "encrypt"), Callable) 28 | assert isinstance(getattr(krypton, "decrypt_to_file"), Callable) 29 | assert isinstance(getattr(krypton, "decrypt_to_memory"), Callable) 30 | assert isinstance(getattr(krypton, "read_file_header"), Callable) 31 | 32 | 33 | def test_krypton_file_enc_dec(krypton_file_helpers: DotMap): 34 | kfh = krypton_file_helpers 35 | 36 | krypton = KryptonFile(kfh.sk) 37 | krypton.encrypt(kfh.pt_file, kfh.ct_file) 38 | krypton.decrypt_to_file(kfh.ct_file, kfh.pt2_file) 39 | 40 | with kfh.pt2_file.open("rb") as file: 41 | pt2 = file.read() 42 | with kfh.ct_file.open("rb") as file: 43 | ct = file.read() 44 | 45 | assert pt2 == kfh.orig_pt 46 | assert ct != kfh.orig_pt 47 | 48 | 49 | def test_krypton_file_enc_dec_callback(krypton_file_helpers: DotMap): 50 | kfh = krypton_file_helpers 51 | 52 | krypton = KryptonFile(kfh.sk, callback=kfh.callback) 53 | krypton.encrypt(kfh.pt_file, kfh.ct_file) 54 | assert sum(kfh.counter) == 4 55 | krypton.decrypt_to_file(kfh.ct_file, kfh.pt2_file) 56 | assert sum(kfh.counter) == 8 57 | 58 | 59 | def test_krypton_file_read_header(krypton_file_helpers: DotMap): 60 | kfh = krypton_file_helpers 61 | 62 | header = b'z' * 32 63 | krypton = KryptonFile(kfh.sk) 64 | krypton.encrypt(kfh.pt_file, kfh.ct_file, header=header) 65 | header2 = krypton.read_file_header(kfh.ct_file) 66 | assert header2 == header 67 | 68 | with pytest.raises(FileNotFoundError): 69 | krypton.read_file_header(Path("asdfgh")) 70 | 71 | 72 | def test_krypton_file_enc_dec_header(krypton_file_helpers: DotMap): 73 | kfh = krypton_file_helpers 74 | header = b'z' * 32 75 | 76 | krypton = KryptonFile(kfh.sk) 77 | krypton.encrypt(kfh.pt_file, kfh.ct_file, header=header) 78 | header2 = krypton.decrypt_to_file(kfh.ct_file, kfh.pt2_file) 79 | assert header2 == header 80 | 81 | 82 | def test_krypton_file_enc_dec_into_memory(krypton_file_helpers: DotMap): 83 | kfh = krypton_file_helpers 84 | 85 | krypton = KryptonFile(kfh.sk) 86 | krypton.encrypt(kfh.pt_file, kfh.ct_file) 87 | dec_data = krypton.decrypt_to_memory(kfh.ct_file) 88 | assert dec_data.plaintext == kfh.orig_pt 89 | 90 | 91 | def test_krypton_file_enc_dec_chunk_size_override(krypton_file_helpers: DotMap): 92 | kfh = krypton_file_helpers 93 | 94 | krypton = KryptonFile(kfh.sk, chunk_size=ChunkSize.KB(1), callback=kfh.callback) 95 | krypton.encrypt(kfh.pt_file, kfh.ct_file) 96 | assert sum(kfh.counter) == 16 97 | dec_data = krypton.decrypt_to_memory(kfh.ct_file) 98 | assert sum(kfh.counter) == 32 99 | assert dec_data.plaintext == kfh.orig_pt 100 | 101 | 102 | def test_krypton_file_enc_dec_errors(krypton_file_helpers: DotMap): 103 | kfh = krypton_file_helpers 104 | 105 | kfh.ct_file.touch() 106 | kfh.pt2_file.touch() 107 | 108 | krypton = KryptonFile(kfh.sk) 109 | with pytest.raises(FileNotFoundError): 110 | krypton.encrypt(Path("asdfg"), kfh.ct_file) 111 | with pytest.raises(FileNotFoundError): 112 | krypton.decrypt_to_file(Path("asdfg"), kfh.pt2_file) 113 | with pytest.raises(FileNotFoundError): 114 | krypton.decrypt_to_memory(Path("asdfg")) 115 | -------------------------------------------------------------------------------- /quantcrypt/internal/cli/commands/enc_dec.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | from typer import Typer 13 | from quantcrypt.cipher import KryptonKEM 14 | from quantcrypt.internal import constants as const 15 | from quantcrypt.internal.cli import tools, console, annotations as ats 16 | 17 | 18 | enc_app = Typer( 19 | name="encrypt", invoke_without_command=True, no_args_is_help=True, 20 | help="Uses an ASCII armored KEM public key to encrypt a file with the Krypton cipher." 21 | ) 22 | dec_app = Typer( 23 | name="decrypt", invoke_without_command=True, no_args_is_help=True, 24 | help="Uses an ASCII armored KEM secret key to decrypt a file with the Krypton cipher." 25 | ) 26 | 27 | 28 | @enc_app.callback() 29 | def command_encrypt( 30 | pk_file: ats.PubKeyFile, 31 | in_file: ats.EncInFile, 32 | out_file: ats.EncOutFile = None, 33 | dry_run: ats.DryRun = False, 34 | overwrite: ats.Overwrite = False, 35 | non_interactive: ats.NonInteractive = False 36 | ) -> None: 37 | paths = tools.process_paths(pk_file, in_file, out_file, const.KryptonFileSuffix) 38 | _common_flow(paths, dry_run, overwrite, non_interactive, const.PQAKeyType.PUBLIC) 39 | 40 | 41 | @dec_app.callback() 42 | def command_decrypt( 43 | sk_file: ats.SecKeyFile, 44 | in_file: ats.DecInFile, 45 | out_file: ats.DecOutFile = None, 46 | dry_run: ats.DryRun = False, 47 | overwrite: ats.Overwrite = False, 48 | non_interactive: ats.NonInteractive = False 49 | ) -> None: 50 | paths = tools.process_paths(sk_file, in_file, out_file, const.KryptonFileSuffix) 51 | _common_flow(paths, dry_run, overwrite, non_interactive, const.PQAKeyType.SECRET) 52 | 53 | 54 | def _common_flow( 55 | paths: tools.CommandPaths, 56 | dry_run: ats.DryRun, 57 | overwrite: ats.Overwrite, 58 | non_interactive: ats.NonInteractive, 59 | key_type: const.PQAKeyType 60 | ) -> None: 61 | console.notify_dry_run(dry_run) 62 | 63 | with paths.key_file.open('r') as file: 64 | armored_key = file.read() 65 | 66 | armor_name = tools.validate_armored_key(armored_key, key_type, const.PQAType.KEM) 67 | 68 | files = [paths.in_file, paths.key_file] 69 | a, b = [f"[italic sky_blue2]{f.name.lower()}[/]" for f in files] 70 | 71 | if key_type == const.PQAKeyType.PUBLIC: 72 | console.styled_print( 73 | f"QuantCrypt is about to encrypt the {a} plaintext file with the \n" 74 | f"{b} KEM PK file into the following binary ciphertext file: \n" 75 | f"[italic tan]{paths.out_file} \n" 76 | ) 77 | else: 78 | console.styled_print( 79 | f"QuantCrypt is about to decrypt the {a} ciphertext file with \n" 80 | f"the {b} KEM SK file into the following plaintext file: \n" 81 | f"[italic tan]{paths.out_file} \n" 82 | ) 83 | 84 | if not non_interactive: 85 | console.ask_continue(exit_on_false=True) 86 | 87 | if paths.out_file.exists(): 88 | console.ask_overwrite_files(non_interactive, overwrite,True) 89 | 90 | if dry_run: 91 | console.styled_print("QuantCrypt would have created the following file:") 92 | console.pretty_print([paths.out_file.as_posix()]) 93 | return 94 | 95 | kem_cls = tools.get_pqa_class(armor_name) 96 | krypton = KryptonKEM(kem_cls) 97 | if key_type == const.PQAKeyType.PUBLIC: 98 | krypton.encrypt( 99 | public_key=armored_key, 100 | data_file=paths.in_file, 101 | output_file=paths.out_file 102 | ) 103 | else: 104 | krypton.decrypt_to_file( 105 | secret_key=armored_key, 106 | encrypted_file=paths.in_file, 107 | output_file=paths.out_file 108 | ) 109 | console.print_success() 110 | -------------------------------------------------------------------------------- /tests/test_pqa/test_kem.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | import pytest 13 | from pathlib import Path 14 | from typing import Callable, Type 15 | from secrets import compare_digest 16 | from pydantic import ValidationError 17 | from quantcrypt.internal import constants as const 18 | from quantcrypt.internal.pqa import kem_algos as kem 19 | from quantcrypt.kem import KEMParamSizes, BaseKEM 20 | from .conftest import BaseAlgorithmTester 21 | 22 | 23 | class TestKemAlgorithms(BaseAlgorithmTester): 24 | @classmethod 25 | def test_mlkem_512(cls): 26 | cls.run_tests(Path(), kem.MLKEM_512) 27 | 28 | @classmethod 29 | def test_mlkem_768(cls): 30 | cls.run_tests(Path(), kem.MLKEM_768) 31 | 32 | @classmethod 33 | def test_mlkem_1024(cls): 34 | cls.run_tests(Path(), kem.MLKEM_1024) 35 | 36 | @classmethod 37 | def run_tests(cls, alt_tmp_path, kem_class: Type[BaseKEM]): 38 | for kem_instance in cls.get_pqa_instances(kem_class): 39 | cls.run_attribute_tests(kem_instance) 40 | cls.run_cryptography_tests(kem_instance) 41 | cls.run_invalid_inputs_tests(kem_instance) 42 | cls.run_armor_success_tests(kem_instance) 43 | cls.run_armor_failure_tests(kem_instance) 44 | cls.run_dearmor_failure_tests(kem_instance) 45 | 46 | @classmethod 47 | def run_attribute_tests(cls, kem_instance: BaseKEM): 48 | cls.notify(kem_instance, "Testing attributes") 49 | 50 | assert hasattr(kem_instance, "spec") 51 | assert isinstance(kem_instance.spec, const.AlgoSpec) 52 | 53 | assert hasattr(kem_instance, "variant") 54 | assert isinstance(kem_instance.variant, const.PQAVariant) 55 | 56 | assert hasattr(kem_instance, "param_sizes") 57 | assert isinstance(kem_instance.param_sizes, KEMParamSizes) 58 | 59 | assert hasattr(kem_instance, "keygen") 60 | assert isinstance(kem_instance.keygen, Callable) 61 | 62 | assert hasattr(kem_instance, "encaps") 63 | assert isinstance(kem_instance.encaps, Callable) 64 | 65 | assert hasattr(kem_instance, "decaps") 66 | assert isinstance(kem_instance.decaps, Callable) 67 | 68 | assert hasattr(kem_instance, "armor") 69 | assert isinstance(kem_instance.armor, Callable) 70 | 71 | assert hasattr(kem_instance, "dearmor") 72 | assert isinstance(kem_instance.dearmor, Callable) 73 | 74 | @classmethod 75 | def run_cryptography_tests(cls, kem_instance: BaseKEM): 76 | cls.notify(kem_instance, "Testing cryptography") 77 | 78 | params = kem_instance.param_sizes 79 | public_key, secret_key = kem_instance.keygen() 80 | 81 | assert isinstance(public_key, bytes) 82 | assert len(public_key) == params.pk_size 83 | assert isinstance(secret_key, bytes) 84 | assert len(secret_key) == params.sk_size 85 | 86 | cipher_text, shared_secret = kem_instance.encaps(public_key) 87 | assert isinstance(cipher_text, bytes) 88 | assert len(cipher_text) == params.ct_size 89 | assert isinstance(shared_secret, bytes) 90 | assert len(shared_secret) == params.ss_size 91 | 92 | decaps_shared_secret = kem_instance.decaps(secret_key, cipher_text) 93 | assert isinstance(decaps_shared_secret, bytes) 94 | assert len(decaps_shared_secret) == params.ss_size 95 | assert compare_digest(shared_secret, decaps_shared_secret) 96 | 97 | @classmethod 98 | def run_invalid_inputs_tests(cls, kem_instance: BaseKEM): 99 | cls.notify(kem_instance, "Testing invalid inputs") 100 | 101 | public_key, secret_key = kem_instance.keygen() 102 | cipher_text, _ = kem_instance.encaps(public_key) 103 | 104 | for ipk in cls.invalid_keys(public_key): 105 | with pytest.raises(ValidationError): 106 | kem_instance.encaps(ipk) 107 | 108 | for isk in cls.invalid_keys(secret_key): 109 | with pytest.raises(ValidationError): 110 | kem_instance.decaps(isk, cipher_text) 111 | 112 | for ict in cls.invalid_ciphertexts(cipher_text): 113 | with pytest.raises(ValidationError): 114 | kem_instance.decaps(secret_key, ict) 115 | -------------------------------------------------------------------------------- /tests/test_cli/conftest.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | import os 13 | import pytest 14 | import typing as t 15 | from pathlib import Path 16 | from dataclasses import dataclass 17 | from contextlib import contextmanager 18 | from collections.abc import Callable 19 | from typer.testing import CliRunner, Result 20 | from quantcrypt.internal.pqa.base_dss import BaseDSS 21 | from quantcrypt.internal.pqa.base_kem import BaseKEM 22 | from quantcrypt.internal.cli.commands import sign_verify 23 | from quantcrypt.internal.cli.commands import enc_dec 24 | from quantcrypt.internal.cli.commands import compile 25 | from quantcrypt.internal.cli.commands import keygen 26 | from quantcrypt.internal.cli.commands import remove 27 | from quantcrypt.internal.cli.commands import info 28 | from quantcrypt.internal.cli.main import app 29 | 30 | 31 | @dataclass(frozen=True) 32 | class CryptoFilePaths: 33 | algorithm: str 34 | public_key_fp: str 35 | secret_key_fp: str 36 | ciphertext_fp: str 37 | plaintext_fp: str 38 | signature_fp: str 39 | ptf_data: bytes 40 | 41 | 42 | class CLIMessages: 43 | ERROR = "QuantCrypt Error" 44 | SUCCESS = "Operation successful" 45 | CANCELLED = "Operation cancelled" 46 | DRYRUN = "DRY RUN MODE" 47 | 48 | 49 | CLIMessages = CLIMessages() 50 | ValidCommands = t.Literal["main", "info", "encrypt", "decrypt", "sign", "verify"] 51 | 52 | 53 | @pytest.fixture(name="cfp_setup", scope="function") 54 | def fixture_cfp_setup(alt_tmp_path) -> Callable[..., t.ContextManager[CryptoFilePaths]]: 55 | @contextmanager 56 | def closure(pqa_class: BaseDSS | BaseKEM) -> t.Generator[CryptoFilePaths, t.Any, None]: 57 | algorithm = pqa_class.armor_name().lower() 58 | cfp_dict = dict( 59 | algorithm=algorithm, 60 | public_key_fp=alt_tmp_path / f"{algorithm}-pubkey.qc", 61 | secret_key_fp=alt_tmp_path / f"{algorithm}-seckey.qc", 62 | ciphertext_fp=alt_tmp_path / "ciphertext.kptn", 63 | plaintext_fp=alt_tmp_path / "plaintext.bin", 64 | signature_fp=alt_tmp_path / "signature.sig", 65 | ptf_data=os.urandom(1024) 66 | ) 67 | cfp = CryptoFilePaths(**{ 68 | k: v.as_posix() if isinstance(v, Path) else v 69 | for k, v in cfp_dict.items() 70 | }) 71 | with open(cfp.plaintext_fp, "wb") as file: 72 | file.write(cfp.ptf_data) 73 | cwd = os.getcwd() 74 | os.chdir(alt_tmp_path) 75 | yield cfp 76 | for item in alt_tmp_path.iterdir(): 77 | item.unlink() 78 | os.chdir(cwd) 79 | return closure 80 | 81 | 82 | @pytest.fixture(name="cli_runner", scope="function") 83 | def fixture_cli_runner() -> Callable[..., Result]: 84 | def closure( 85 | command: ValidCommands = "main", 86 | options: list[str] = None, 87 | user_input: str = None, 88 | expected_stdout: str = None, 89 | debug: bool = False 90 | ) -> Result: 91 | runner = CliRunner() 92 | _app = dict( 93 | main=app, 94 | info=info.info_app, 95 | keygen=keygen.keygen_app, 96 | encrypt=enc_dec.enc_app, 97 | decrypt=enc_dec.dec_app, 98 | sign=sign_verify.sign_app, 99 | verify=sign_verify.verify_app, 100 | compile=compile.compile_app, 101 | remove=remove.remove_app 102 | )[command] 103 | result = runner.invoke(_app, options, input=user_input) 104 | if debug: 105 | print(result.output) 106 | expected_exit_code = 1 107 | if expected_stdout != CLIMessages.ERROR: 108 | expected_exit_code = 0 109 | assert result.exit_code == expected_exit_code 110 | assert (expected_stdout or '') in result.stdout 111 | return result 112 | return closure 113 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 5 | _NOTE: This changelog is generated and managed by [devtools-cli](https://pypi.org/project/devtools-cli/), **do not edit manually**._ 6 | 7 | 8 | ### [1.0.1] - 2025-03-20 - _latest_ 9 | 10 | - CLI command 'remove' now supports new option '--only-ref' 11 | - Fixed SonarCloud complaints and improved pytest coverage 12 | 13 | ### [1.0.0] - 2025-03-18 14 | 15 | - Major refactor of large parts of the codebase and also pytests. 16 | - Replaced Kyber and Dilithium classes with MLKEM_* and MLDSA_* classes. Other breaking changes as well. 17 | - QuantCrypt now supports wider variety of PQC algorithms from the PQClean project. 18 | - Reworked compiler directly into the QuantCrypt library, so it becomes possible to compile PQA binaries on platforms for which binary wheels are not available on PyPI registry. 19 | - Added compile command to CLI. This command becomes available when QuantCrypt has been installed with optional dependencies required by the compiler component. 20 | - QuantCrypt now correctly builds binary wheels on GitHub Actions for all supported Python versions on Windows, Linux and MacOS platforms. 21 | 22 | ### [0.4.2] - 2024-02-05 23 | 24 | - Restored --version and --info options to qclib CLI command 25 | 26 | ### [0.4.1] - 2024-02-04 27 | 28 | - Prettified encrypt and decrypt CLI commands 29 | - Prettified sign and verify CLI commands 30 | - Added pytests for all CLI commands 31 | - FastSphincs and SmallSphincs now generate armored keyfiles 32 | without underscores in their names in the keyfile envelopes 33 | 34 | ### [0.3.4] - 2024-02-04 35 | 36 | - Updated PQClean dependency to commit 3b43bc6 37 | - Prettified keygen and optimize CLI command 38 | - Improved --help docs for qclib CLI commands 39 | - Fixed an issue with precompiled binaries Python version 40 | 41 | ### [0.3.3] - 2024-01-28 42 | 43 | - Added security statement to README.md 44 | - KryptonKEM now accepts relative paths as parameter inputs 45 | - KryptonFile now accepts strings and relative paths as parameter inputs 46 | - DSS sign_file and verify_file now accept relative paths as parameter inputs 47 | 48 | ### [0.3.2] - 2024-01-23 49 | 50 | - Updated wiki link 51 | 52 | ### [0.3.1] - 2024-01-21 53 | 54 | - Added sign_file and verify_file methods to BaseDSS class 55 | - Added sign and verify CLI commands 56 | 57 | ### [0.3.0] - 2024-01-21 58 | 59 | - Reduced KryptonKEM memory cost from 2GB to 1GB. This still requires 10^77 GB of memory 60 | to brute force all 256 bit combinations, which is astronomically unattainable. 61 | - Improved docstrings across multiple classes, methods and CLI commands. 62 | - KryptonKEM now accepts ASCII armored keys as key argument values for encrypt and decrypt methods. 63 | - Implemented encrypt and decrypt CLI commands. 64 | 65 | ### [0.2.0] - 2024-01-19 66 | 67 | - Added keygen subcommand for qclib CLI 68 | - Implemented the KryptonFile class for file cryptography 69 | - Doubled the memory cost of Argon2 default security parameters 70 | - Argon2 now outputs 64 byte hashes by default 71 | - Implemented the KryptonKEM class which uses asymmetric KEM keys 72 | - Changed KEM keyfile suffixes from .qclib to .qc in CLI keygen subcommand 73 | 74 | ### [0.1.3] - 2024-01-12 75 | 76 | - Added CHANGELOG.md 77 | - Renamed MemSize class in KDF module to MemCost and changed its interface 78 | - Added CLI command `qclib` with options `--info` and `--version` 79 | 80 | [1.0.1]: https://github.com/aabmets/quantcrypt/compare/1.0.0...1.0.1 81 | [1.0.0]: https://github.com/aabmets/quantcrypt/compare/0.4.2...1.0.0 82 | [0.4.2]: https://github.com/aabmets/quantcrypt/compare/0.4.0...0.4.2 83 | [0.4.0]: https://github.com/aabmets/quantcrypt/compare/0.3.4...0.4.0 84 | [0.3.4]: https://github.com/aabmets/quantcrypt/compare/0.3.3...0.3.4 85 | [0.3.3]: https://github.com/aabmets/quantcrypt/compare/0.3.2...0.3.3 86 | [0.3.2]: https://github.com/aabmets/quantcrypt/compare/0.3.1...0.3.2 87 | [0.3.1]: https://github.com/aabmets/quantcrypt/compare/0.3.0...0.3.1 88 | [0.3.0]: https://github.com/aabmets/quantcrypt/compare/0.2.0...0.3.0 89 | [0.2.0]: https://github.com/aabmets/quantcrypt/compare/0.1.3...0.2.0 90 | [0.1.3]: https://github.com/aabmets/quantcrypt/compare/0.1.0...0.1.3 -------------------------------------------------------------------------------- /tests/test_pqclean.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | import pytest 13 | import platform 14 | import itertools 15 | from pathlib import Path 16 | from unittest.mock import patch 17 | from quantcrypt.internal import pqclean, constants as const 18 | 19 | 20 | def _validate_common_filepaths(variant: const.PQAVariant) -> None: 21 | data = pqclean.get_common_filepaths(variant) 22 | assert Path(data[0]).is_dir() 23 | for cp in [Path(p) for p in data[1]]: 24 | assert cp.is_file() 25 | assert cp.suffix in ['.c', '.S', '.s'] 26 | 27 | 28 | def _check_windows_support(spec: const.AlgoSpec, variant: const.PQAVariant) -> None: 29 | with pytest.MonkeyPatch.context() as mpc: 30 | mpc.setattr(platform, "system", lambda: "Windows") 31 | mpc.setattr(platform, "machine", lambda: "x86_64") 32 | res1, res2 = pqclean.check_platform_support(spec, variant) 33 | if variant == const.PQAVariant.OPT_ARM: 34 | assert res1 is None and res2 is None 35 | return 36 | elif any(n in spec.class_name for n in ["FALCON", "SPHINCS"]): 37 | assert res1 is not None and res2 is not None 38 | return 39 | assert res1 is None and res2 is None 40 | 41 | 42 | def _check_linux_support(spec: const.AlgoSpec, variant: const.PQAVariant) -> None: 43 | opt_amd, opt_arm = const.PQAVariant.OPT_AMD, const.PQAVariant.OPT_ARM 44 | for arch, _variant in [("x86_64", opt_amd), ("arm_8", opt_arm)]: 45 | with pytest.MonkeyPatch.context() as mpc: 46 | mpc.setattr(platform, "system", lambda: "Linux") 47 | mpc.setattr(platform, "machine", lambda: arch) # NOSONAR 48 | res1, res2 = pqclean.check_platform_support(spec, variant) 49 | if _variant == variant: 50 | assert res1 is not None and res2 is not None 51 | return 52 | assert res1 is None and res2 is None 53 | 54 | 55 | def test_pqclean_sources(alt_tmp_path): 56 | pqclean_dir = alt_tmp_path / "pqclean" 57 | pqclean_dir.mkdir(parents=True, exist_ok=True) 58 | 59 | with patch('internal.pqclean.utils.search_upwards') as mock: 60 | mock.side_effect = lambda *_, **__: pqclean_dir 61 | 62 | assert pqclean.check_sources_exist(pqclean_dir) is False 63 | pqclean.download_extract_pqclean(pqclean_dir) 64 | assert pqclean.check_sources_exist(pqclean_dir) is True 65 | 66 | for variant in const.PQAVariant.members(): 67 | _validate_common_filepaths(variant) 68 | 69 | specs = const.SupportedAlgos 70 | variants = const.PQAVariant.members() 71 | 72 | for spec, variant in itertools.product(specs, variants): # type: const.AlgoSpec, const.PQAVariant 73 | if variant == const.PQAVariant.REF: 74 | res1, res2 = pqclean.check_platform_support(spec, variant) 75 | assert res1 is not None and res2 is not None 76 | continue 77 | _check_windows_support(spec, variant) 78 | _check_linux_support(spec, variant) 79 | 80 | 81 | def test_find_pqclean_dir(alt_tmp_path): 82 | nested_pqclean_dir = alt_tmp_path / "quantcrypt/pqclean" 83 | upper_pqclean_dir = alt_tmp_path / "pqclean" 84 | 85 | def _mocked_search_upwards(_, from_path = None): 86 | ret_path = nested_pqclean_dir 87 | if from_path is not None: 88 | ret_path = upper_pqclean_dir 89 | ret_path.mkdir(parents=True, exist_ok=True) 90 | return ret_path 91 | 92 | with patch('internal.pqclean.utils.search_upwards') as mock: 93 | mock.side_effect = _mocked_search_upwards 94 | 95 | pqclean_dir = pqclean.find_pqclean_dir(src_must_exist=False) 96 | assert pqclean_dir == nested_pqclean_dir 97 | 98 | with pytest.raises(RuntimeError): 99 | pqclean.find_pqclean_dir(src_must_exist=True) 100 | 101 | pqclean.download_extract_pqclean(upper_pqclean_dir) 102 | pqclean_dir = pqclean.find_pqclean_dir(src_must_exist=True) 103 | assert pqclean_dir == upper_pqclean_dir 104 | -------------------------------------------------------------------------------- /quantcrypt/internal/cli/tools.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | import re 13 | from pathlib import Path 14 | from typing import Type 15 | from dotmap import DotMap 16 | from dataclasses import dataclass 17 | from quantcrypt.internal import utils, constants as const 18 | from quantcrypt.internal.cli import console 19 | from quantcrypt.internal.pqa import dss_algos 20 | from quantcrypt.internal.pqa import kem_algos 21 | from quantcrypt.internal.pqa.base_kem import BaseKEM 22 | from quantcrypt.internal.pqa.base_dss import BaseDSS 23 | 24 | 25 | __all__ = [ 26 | "CommandPaths", 27 | "resolve_optional_file", 28 | "resolve_directory", 29 | "process_paths", 30 | "validate_armored_key", 31 | "get_pqa_class" 32 | ] 33 | 34 | 35 | @dataclass 36 | class CommandPaths: 37 | key_file: Path 38 | in_file: Path 39 | out_file: Path 40 | sig_file: Path 41 | 42 | 43 | def resolve_optional_file( 44 | optional_file: str | None, 45 | from_file: Path, 46 | new_suffix: str 47 | ) -> Path: 48 | if not optional_file: 49 | return from_file.with_suffix(new_suffix) 50 | return utils.resolve_relpath(optional_file) 51 | 52 | 53 | def resolve_directory(dir_arg: str | None) -> Path: 54 | target_dir = utils.resolve_relpath(dir_arg) 55 | if target_dir.is_file(): 56 | msg = f"The provided path is not a directory: [italic tan]{target_dir}" 57 | console.raise_error(msg) 58 | elif not target_dir.exists(): 59 | target_dir.mkdir(parents=True) 60 | return target_dir 61 | 62 | 63 | def process_paths( 64 | key_file: str, 65 | in_file: str, 66 | out_file: str, 67 | new_suffix: str, 68 | out_file_must_exist: bool = False 69 | ) -> CommandPaths: 70 | _key_file = utils.resolve_relpath(key_file) 71 | _in_file = utils.resolve_relpath(in_file) 72 | _out_file = resolve_optional_file( 73 | optional_file=out_file, 74 | from_file=_in_file, 75 | new_suffix=new_suffix 76 | ) 77 | files = [_key_file, _in_file] 78 | if out_file_must_exist: 79 | files.append(_out_file) 80 | 81 | for file in files: 82 | if not file.is_file(): 83 | console.raise_error( 84 | f"File [italic sky_blue2]{file.name}[/] does not " 85 | f"exist in directory:\n[italic tan]{file.parent}" 86 | ) 87 | return CommandPaths( 88 | key_file=_key_file, 89 | in_file=_in_file, 90 | out_file=_out_file, 91 | sig_file=_out_file 92 | ) 93 | 94 | 95 | def validate_armored_key( 96 | armored_key: str, 97 | key_type: const.PQAKeyType, 98 | pqa_type: const.PQAType 99 | ) -> str: 100 | header_pattern = r"^-----BEGIN (?P\w+) (?P[A-Z_]+) KEY-----\n" 101 | footer_pattern = r"\n-----END (?P\w+) (?P[A-Z_]+) KEY-----$" 102 | full_pattern = header_pattern + r"(?P.+)" + footer_pattern 103 | 104 | full_match = re.match(full_pattern, armored_key, re.DOTALL) 105 | fm = DotMap(full_match.groupdict()) if full_match else None 106 | 107 | if not fm or not fm.content or not fm.content.strip(): 108 | console.raise_error("The armored key is corrupted.") 109 | elif fm.hdr_name != fm.ftr_name or fm.hdr_type != fm.ftr_type: 110 | console.raise_error("The envelope of the armored key is corrupted.") 111 | elif fm.hdr_name not in const.SupportedAlgos.armor_names(pqa_type): 112 | console.raise_error(f"Unsupported algorithm {fm.hdr_name} in armored key header.") 113 | elif fm.hdr_type not in const.PQAKeyType.values(): 114 | console.raise_error(f"Unsupported key type {fm.hdr_type} in armored key header.") 115 | elif fm.hdr_type != key_type.value: 116 | console.raise_error( 117 | f"Expected a {key_type.value.lower()} key, but " 118 | f"received a {fm.hdr_type.lower()} key instead." 119 | ) 120 | return fm.hdr_name # NOSONAR 121 | 122 | 123 | def get_pqa_class(armor_name: str) -> Type[BaseKEM | BaseDSS]: 124 | for spec in const.SupportedAlgos: 125 | if spec.armor_name() == armor_name.upper(): 126 | is_kem = spec.type == const.PQAType.KEM 127 | module = kem_algos if is_kem else dss_algos 128 | return getattr(module, spec.class_name) 129 | console.raise_error(f"Algorithm name '{armor_name}' does not map to any supported PQA class.") 130 | raise # NOSONAR pragma: no cover 131 | -------------------------------------------------------------------------------- /quantcrypt/internal/cli/commands/sign_verify.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | from typer import Typer 13 | from quantcrypt.internal.pqa.base_dss import BaseDSS 14 | from quantcrypt.internal import errors, constants as const 15 | from quantcrypt.internal.cli import tools, console, annotations as ats 16 | 17 | 18 | sign_app = Typer( 19 | name="sign", invoke_without_command=True, no_args_is_help=True, 20 | help="Uses an ASCII armored DSS secret key to generate a signature for a file." 21 | ) 22 | verify_app = Typer( 23 | name="verify", invoke_without_command=True, no_args_is_help=True, 24 | help="Uses an ASCII armored DSS public key to verify the signature of a file." 25 | ) 26 | 27 | 28 | @sign_app.callback() 29 | def command_sign( 30 | sk_file: ats.SecKeyFile, 31 | in_file: ats.SignDataFile, 32 | sig_file: ats.WriteSigFile = None, 33 | dry_run: ats.DryRun = False, 34 | overwrite: ats.Overwrite = False, 35 | non_interactive: ats.NonInteractive = False 36 | ) -> None: 37 | paths, dss_inst, armored_key = _common_flow( 38 | sk_file, in_file, sig_file, dry_run, non_interactive, 39 | False, const.PQAKeyType.SECRET 40 | ) 41 | if paths.sig_file.exists(): 42 | console.ask_overwrite_files(non_interactive, overwrite, True) 43 | if dry_run: 44 | console.styled_print("QuantCrypt would have created the following signature file:") 45 | console.pretty_print([paths.out_file.as_posix()]) 46 | return 47 | try: 48 | signed_file = dss_inst.sign_file(armored_key, paths.in_file) 49 | with paths.sig_file.open('wb') as file: 50 | file.write(signed_file.signature) 51 | console.print_success() 52 | except errors.QuantCryptError: # pragma: no cover 53 | msg = "Unable to sign the data file. Is the secret key valid?" 54 | console.raise_error(msg) 55 | 56 | 57 | @verify_app.callback() 58 | def command_verify( 59 | pk_file: ats.PubKeyFile, 60 | in_file: ats.VerifyDataFile, 61 | sig_file: ats.ReadSigFile = None, 62 | dry_run: ats.DryRun = False, 63 | non_interactive: ats.NonInteractive = False 64 | ) -> None: 65 | paths, dss, armored_key = _common_flow( 66 | pk_file, in_file, sig_file, dry_run, non_interactive, 67 | True, const.PQAKeyType.PUBLIC 68 | ) 69 | if dry_run: 70 | console.styled_print("QuantCrypt would have verified the following signature file:") 71 | console.pretty_print([paths.sig_file.as_posix()]) 72 | return 73 | try: 74 | with paths.sig_file.open('rb') as file: 75 | signature = file.read() 76 | dss.verify_file(armored_key, paths.in_file, signature) 77 | console.print_success() 78 | except errors.QuantCryptError: # pragma: no cover 79 | msg = "Unable to verify data file signature! Is the public key valid?" 80 | console.raise_error(msg) 81 | 82 | 83 | def _common_flow( 84 | key_file: str, 85 | in_file: str, 86 | sig_file: str, 87 | dry_run: bool, 88 | non_interactive: bool, 89 | sig_file_must_exist: bool, 90 | key_type: const.PQAKeyType 91 | ) -> tuple[tools.CommandPaths, BaseDSS, str]: 92 | paths = tools.process_paths( 93 | key_file, 94 | in_file, 95 | sig_file, 96 | const.SignatureFileSuffix, 97 | sig_file_must_exist 98 | ) 99 | console.notify_dry_run(dry_run) 100 | 101 | with paths.key_file.open('r') as file: 102 | armored_key = file.read() 103 | 104 | algo_name = tools.validate_armored_key(armored_key, key_type, const.PQAType.DSS) 105 | 106 | files = [paths.in_file, paths.key_file] 107 | a, b = [f"[italic sky_blue2]{f.name.lower()}[/]" for f in files] 108 | 109 | if key_type == const.PQAKeyType.SECRET: 110 | console.styled_print( 111 | f"QuantCrypt is about to sign the {a} data file with the \n" 112 | f"{b} DSS SK file to create the following signature file: \n" 113 | f"[italic tan]{paths.sig_file} \n" 114 | ) 115 | else: 116 | console.styled_print( 117 | f"QuantCrypt is about to verify the {a} data file with the \n" 118 | f"{b} DSS PK file and the following signature file: \n" 119 | f"[italic tan]{paths.sig_file} \n" 120 | ) 121 | if not non_interactive: 122 | console.ask_continue(exit_on_false=True) 123 | 124 | dss_cls = tools.get_pqa_class(algo_name) 125 | return paths, dss_cls(), armored_key 126 | -------------------------------------------------------------------------------- /quantcrypt/internal/errors.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | import platform 13 | from typing import Literal 14 | from quantcrypt.internal import constants as const 15 | 16 | 17 | __all__ = [ 18 | "QuantCryptError", 19 | "InvalidUsageError", 20 | "InvalidArgsError", 21 | "UnsupportedPlatformError", 22 | 23 | "PQAError", 24 | "PQAImportError", 25 | "PQAUnsupportedAlgoError", 26 | "PQAKeyArmorError", 27 | "KEMKeygenFailedError", 28 | "KEMEncapsFailedError", 29 | "KEMDecapsFailedError", 30 | "DSSKeygenFailedError", 31 | "DSSSignFailedError", 32 | "DSSVerifyFailedError", 33 | 34 | "KDFError", 35 | "KDFOutputLimitError", 36 | "KDFWeakPasswordError", 37 | "KDFVerificationError", 38 | "KDFInvalidHashError", 39 | "KDFHashingError", 40 | 41 | "CipherError", 42 | "CipherStateError", 43 | "CipherVerifyError", 44 | "CipherChunkSizeError", 45 | "CipherPaddingError" 46 | ] 47 | 48 | 49 | class QuantCryptError(Exception): 50 | """Base class for all QuantCrypt errors.""" 51 | 52 | 53 | class InvalidUsageError(QuantCryptError): 54 | def __init__(self, message: str = None): 55 | super().__init__(message or "Invalid usage of object.") 56 | 57 | 58 | class InvalidArgsError(QuantCryptError): 59 | def __init__(self, message: str = None): 60 | super().__init__(message or "Method received invalid arguments.") 61 | 62 | 63 | class UnsupportedPlatformError(QuantCryptError): 64 | def __init__(self): 65 | super().__init__(f"Operating system '{platform.system()}' not supported.") 66 | 67 | 68 | class PQAError(QuantCryptError): 69 | """Base class for all PQC errors.""" 70 | 71 | 72 | class PQAImportError(PQAError): 73 | def __init__(self, spec: const.AlgoSpec, variant: const.PQAVariant): 74 | super().__init__(f"Failed to import {variant.value} binaries of the {spec.class_name} algorithm.") 75 | 76 | 77 | class PQAUnsupportedAlgoError(PQAError): 78 | def __init__(self, cls_name: str): 79 | super().__init__(f"Unsupported PQA class '{cls_name}'.") 80 | 81 | 82 | class PQAKeyArmorError(PQAError): 83 | def __init__(self, verb: Literal["armor", "dearmor"]): 84 | super().__init__(f"QuantCrypt will not {verb} a corrupted key.") 85 | 86 | 87 | class KEMKeygenFailedError(PQAError): 88 | def __init__(self): 89 | super().__init__("QuantCrypt KEM keygen failed.") 90 | 91 | 92 | class KEMEncapsFailedError(PQAError): 93 | def __init__(self): 94 | super().__init__("QuantCrypt KEM encaps failed.") 95 | 96 | 97 | class KEMDecapsFailedError(PQAError): 98 | def __init__(self): 99 | super().__init__("QuantCrypt KEM decaps failed.") 100 | 101 | 102 | class DSSKeygenFailedError(PQAError): 103 | def __init__(self): 104 | super().__init__("QuantCrypt DSS keygen failed.") 105 | 106 | 107 | class DSSSignFailedError(PQAError): 108 | def __init__(self): 109 | super().__init__("QuantCrypt DSS sign failed.") 110 | 111 | 112 | class DSSVerifyFailedError(PQAError): 113 | def __init__(self): 114 | super().__init__("QuantCrypt DSS verify failed.") 115 | 116 | 117 | class KDFError(QuantCryptError): 118 | """Base class for all KDF errors.""" 119 | 120 | 121 | class KDFOutputLimitError(KDFError): 122 | def __init__(self, limit: int): 123 | super().__init__(f"Not allowed to derive more than {limit} bytes of keys from one master key.") 124 | 125 | 126 | class KDFWeakPasswordError(KDFError): 127 | def __init__(self): 128 | super().__init__("Weak passwords are not allowed.") 129 | 130 | 131 | class KDFVerificationError(KDFError): 132 | def __init__(self): 133 | super().__init__("KDF failed to verify the password against the provided public hash.") 134 | 135 | 136 | class KDFInvalidHashError(KDFError): 137 | def __init__(self): 138 | super().__init__("KDF was provided with an invalid hash for verification.") 139 | 140 | 141 | class KDFHashingError(KDFError): 142 | def __init__(self): 143 | super().__init__("KDF was unable to hash the password due to an internal error.") 144 | 145 | 146 | class CipherError(QuantCryptError): 147 | """Base class for all Cipher errors.""" 148 | 149 | 150 | class CipherStateError(CipherError): 151 | def __init__(self): 152 | super().__init__("Cannot call this method in the current cipher state.") 153 | 154 | 155 | class CipherVerifyError(CipherError): 156 | def __init__(self): 157 | super().__init__("Cannot verify the decrypted data with the provided digest.") 158 | 159 | 160 | class CipherChunkSizeError(CipherError): 161 | def __init__(self): 162 | super().__init__("Data is larger than the allowed chunk size.") 163 | 164 | 165 | class CipherPaddingError(CipherError): 166 | def __init__(self): 167 | super().__init__("The padding of the decrypted plaintext is incorrect.") 168 | -------------------------------------------------------------------------------- /tests/test_cipher/test_krypton.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | import pytest 13 | from typing import Callable 14 | from pydantic import ValidationError 15 | from quantcrypt.cipher import Krypton, ChunkSize 16 | from quantcrypt.internal import errors 17 | 18 | 19 | def test_krypton_attributes(): 20 | krypton = Krypton( 21 | secret_key=b'x' * 64, 22 | context=b'z' * 16, 23 | chunk_size=ChunkSize.KB(1) 24 | ) 25 | assert isinstance(krypton, Krypton), \ 26 | f"Expected an instance of Krypton, but received {type(krypton)}" 27 | 28 | for method in [ 29 | "flush", 30 | "begin_encryption", 31 | "encrypt", 32 | "finish_encryption", 33 | "begin_decryption", 34 | "decrypt", 35 | "finish_decryption" 36 | ]: 37 | assert hasattr(krypton, method) 38 | assert isinstance(getattr(krypton, method), Callable) 39 | 40 | 41 | def test_krypton_invalid_key_arg(): 42 | with pytest.raises(ValidationError): 43 | Krypton(b'x' * 63) 44 | with pytest.raises(ValidationError): 45 | Krypton(b'x' * 65) 46 | 47 | 48 | def test_krypton_basic_workflow(): 49 | secret_key = b'x' * 64 50 | plaintext = b'abcd' * 25 51 | header = b'z' * 16 52 | 53 | k1 = Krypton(secret_key) 54 | k1.begin_encryption(header) 55 | ct = k1.encrypt(plaintext) 56 | digest = k1.finish_encryption() 57 | 58 | assert len(ct) == 100 59 | 60 | k2 = Krypton(secret_key) 61 | k2.begin_decryption(digest, header) 62 | pt = k2.decrypt(ct) 63 | k2.finish_decryption() 64 | 65 | assert pt == plaintext 66 | 67 | 68 | def test_krypton_chunked_workflow(): 69 | secret_key = b'x' * 64 70 | plaintext = b'abcd' * 25 71 | header = b'z' * 16 72 | 73 | k1 = Krypton(secret_key, chunk_size=ChunkSize.KB(1)) 74 | k1.begin_encryption(header) 75 | ciphertext = k1.encrypt(plaintext) 76 | digest = k1.finish_encryption() 77 | 78 | assert len(ciphertext) == 1024 + 1 79 | 80 | k2 = Krypton(secret_key, chunk_size=ChunkSize.KB(1)) 81 | k2.begin_decryption(digest, header) 82 | pt = k2.decrypt(ciphertext) 83 | k2.finish_decryption() 84 | 85 | assert pt == plaintext 86 | 87 | 88 | def test_krypton_chunked_errors(): 89 | secret_key = b'x' * 64 90 | plaintext = b'abcd' * 25 91 | 92 | k1 = Krypton(secret_key, chunk_size=ChunkSize.KB(1)) 93 | k1.begin_encryption() 94 | with pytest.raises(errors.CipherChunkSizeError): 95 | k1.encrypt(b'x' * 1025) 96 | ciphertext = k1.encrypt(plaintext) 97 | digest = k1.finish_encryption() 98 | 99 | k2 = Krypton(secret_key, chunk_size=ChunkSize.KB(1)) 100 | k2.begin_decryption(digest) 101 | with pytest.raises(errors.CipherChunkSizeError): 102 | k2.decrypt(b'x' * 1024) 103 | with pytest.raises(errors.CipherChunkSizeError): 104 | k2.decrypt(b'x' * 1026) 105 | with pytest.raises(errors.CipherPaddingError): 106 | k2.decrypt(ciphertext[::-1]) 107 | 108 | 109 | def test_krypton_invalid_digest(): 110 | secret_key = b'x' * 64 111 | header = b'z' * 16 112 | 113 | k1 = Krypton(secret_key) 114 | k1.begin_encryption(header) 115 | digest = k1.finish_encryption() 116 | 117 | digest = digest[::-1] # Corrupt digest 118 | 119 | k2 = Krypton(secret_key) 120 | with pytest.raises(errors.CipherVerifyError): 121 | k2.begin_decryption(digest, header) 122 | 123 | 124 | def test_krypton_invalid_ciphertext(): 125 | secret_key = b'x' * 64 126 | plaintext = b'abcd' * 25 127 | header = b'z' * 16 128 | 129 | k1 = Krypton(secret_key) 130 | k1.begin_encryption(header) 131 | ciphertext = k1.encrypt(plaintext) 132 | digest = k1.finish_encryption() 133 | 134 | ciphertext = ciphertext[::-1] # Corrupt ciphertext 135 | 136 | k2 = Krypton(secret_key) 137 | k2.begin_decryption(digest, header) 138 | k2.decrypt(ciphertext) 139 | with pytest.raises(errors.CipherVerifyError): 140 | k2.finish_decryption() 141 | 142 | 143 | def test_krypton_invalid_state_error(): 144 | k = Krypton(b'x' * 64) 145 | 146 | k.begin_encryption() 147 | with pytest.raises(errors.CipherStateError): 148 | k.begin_decryption(b'x' * 160) 149 | with pytest.raises(errors.CipherStateError): 150 | k.decrypt(b'') 151 | with pytest.raises(errors.CipherStateError): 152 | k.finish_decryption() 153 | 154 | digest = k.finish_encryption() 155 | k.flush() 156 | 157 | k.begin_decryption(digest) 158 | with pytest.raises(errors.CipherStateError): 159 | k.begin_encryption() 160 | with pytest.raises(errors.CipherStateError): 161 | k.encrypt(b'') 162 | with pytest.raises(errors.CipherStateError): 163 | k.finish_encryption() 164 | -------------------------------------------------------------------------------- /quantcrypt/internal/pqa/base_kem.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | from abc import ABC 13 | from cffi import FFI 14 | from types import ModuleType 15 | from functools import cache 16 | from quantcrypt.internal import utils, errors 17 | from quantcrypt.internal.pqa import common as com 18 | 19 | 20 | __all__ = ["KEMParamSizes", "BaseKEM"] 21 | 22 | 23 | class KEMParamSizes(com.BasePQAParamSizes): 24 | def __init__(self, lib: ModuleType, cdef_name: str): 25 | self.ct_size = getattr(lib, f"{cdef_name}_CRYPTO_CIPHERTEXTBYTES") 26 | self.ss_size = getattr(lib, f"{cdef_name}_CRYPTO_BYTES") 27 | super().__init__(lib, cdef_name) 28 | 29 | 30 | class BaseKEM(com.BasePQAlgorithm, ABC): 31 | @property 32 | @cache 33 | def param_sizes(self) -> KEMParamSizes: 34 | return KEMParamSizes(self._lib, self._cdef_name) 35 | 36 | def keygen(self) -> tuple[bytes, bytes]: 37 | """ 38 | Generates a tuple of bytes, where the first bytes object is 39 | the public key and the second bytes object is the secret key. 40 | :return: tuple of public key bytes and secret key bytes, in this order. 41 | :raises - errors.KEMKeygenFailedError: When the underlying CFFI 42 | library has failed to generate the keys for the current 43 | KEM algorithm for any reason. 44 | """ 45 | return self._keygen("kem", errors.KEMKeygenFailedError) 46 | 47 | def encaps(self, public_key: bytes) -> tuple[bytes, bytes]: 48 | """ 49 | Internally generates a shared secret and then tries to 50 | encapsulate it into a ciphertext using the provided public key. 51 | 52 | :param public_key: The public key which is used to 53 | encapsulate the internally generated shared secret. 54 | :return: tuple of ciphertext bytes and shared secret bytes, in this order. 55 | :raises - pydantic.ValidationError: When the user-provided 56 | `public_key` value has invalid type or its length is 57 | invalid for the current KEM algorithm. 58 | :raises - errors.KEMEncapsFailedError: When the underlying 59 | CFFI library has failed to encapsulate the shared 60 | secret for any reason. 61 | """ 62 | params = self.param_sizes 63 | pk_atd = utils.annotated_bytes(equal_to=params.pk_size) 64 | 65 | @utils.input_validator() 66 | def _encaps(pk: pk_atd) -> tuple[bytes, bytes]: 67 | ffi = FFI() 68 | cipher_text = ffi.new(f"uint8_t [{params.ct_size}]") 69 | shared_secret = ffi.new(f"uint8_t [{params.ss_size}]") 70 | 71 | func = getattr(self._lib, self._cdef_name + "_crypto_kem_enc") 72 | if 0 != func(cipher_text, shared_secret, pk): # pragma: no cover 73 | raise errors.KEMEncapsFailedError 74 | 75 | ct = ffi.buffer(cipher_text, params.ct_size) 76 | ss = ffi.buffer(shared_secret, params.ss_size) 77 | return bytes(ct), bytes(ss) 78 | 79 | return _encaps(public_key) 80 | 81 | def decaps(self, secret_key: bytes, cipher_text: bytes) -> bytes: 82 | """ 83 | Tries to extract the encapsulated shared secret from the 84 | provided ciphertext using the provided secret key. 85 | 86 | :param secret_key: The secret key which is used to 87 | decapsulate the provided `cipher_text` bytes object. 88 | :param cipher_text: The ciphertext from which to extract 89 | the shared secret using the provided `secret_key`. 90 | :return: Bytes of the shared secret. 91 | :raises - pydantic.ValidationError: When the user-provided 92 | `secret_key` or `cipher_text` values have invalid types 93 | or their lengths are invalid for the current KEM algorithm. 94 | :raises - errors.KEMDecapsFailedError: When the underlying 95 | CFFI library has failed to decapsulate the shared 96 | secret from the ciphertext for any reason. 97 | """ 98 | params = self.param_sizes 99 | sk_atd = utils.annotated_bytes(equal_to=params.sk_size) 100 | ct_atd = utils.annotated_bytes(equal_to=params.ct_size) 101 | 102 | @utils.input_validator() 103 | def _decaps(sk: sk_atd, ct: ct_atd) -> bytes: 104 | ffi = FFI() 105 | shared_secret = ffi.new(f"uint8_t [{params.ss_size}]") 106 | 107 | func = getattr(self._lib, self._cdef_name + "_crypto_kem_dec") 108 | if 0 != func(shared_secret, ct, sk): # pragma: no cover 109 | raise errors.KEMDecapsFailedError 110 | 111 | ss = ffi.buffer(shared_secret, params.ss_size) 112 | return bytes(ss) 113 | 114 | return _decaps(secret_key, cipher_text) 115 | -------------------------------------------------------------------------------- /tests/test_cli/test_tools.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | import pytest 13 | import string 14 | import secrets 15 | import itertools 16 | from pathlib import Path 17 | from quantcrypt.internal import constants as const 18 | from quantcrypt.internal.cli import tools 19 | from quantcrypt.internal.pqa.base_kem import BaseKEM 20 | from quantcrypt.internal.pqa.base_dss import BaseDSS 21 | 22 | 23 | def test_resolve_optional_file(): 24 | path = tools.resolve_optional_file( 25 | optional_file=None, 26 | from_file=Path("asdfg.bin"), 27 | new_suffix=".txt" 28 | ) 29 | assert path.name == "asdfg.txt" 30 | 31 | 32 | def test_resolve_directory(tmp_path: Path): 33 | tmp_file = tmp_path / "file.txt" 34 | tmp_file.touch() 35 | 36 | with pytest.raises(SystemExit, match='1'): 37 | tools.resolve_directory(tmp_file.as_posix()) 38 | 39 | sub_dir = tmp_path / "sub/dir" 40 | tools.resolve_directory(sub_dir.as_posix()) 41 | assert sub_dir.exists() 42 | 43 | 44 | def test_process_paths(tmp_path: Path): 45 | key_file = tmp_path / "key_file.txt" 46 | in_file = tmp_path / "in_file.txt" 47 | out_file = tmp_path / "out_file.txt" 48 | 49 | with pytest.raises(SystemExit, match='1'): 50 | tools.process_paths( 51 | key_file=key_file.as_posix(), 52 | in_file=in_file.as_posix(), 53 | out_file="", 54 | new_suffix=".suf" 55 | ) 56 | 57 | key_file.touch() 58 | in_file.touch() 59 | out_file.touch() 60 | 61 | res = tools.process_paths( 62 | key_file=key_file.as_posix(), 63 | in_file=in_file.as_posix(), 64 | out_file=out_file.as_posix(), 65 | new_suffix=".suf" 66 | ) 67 | assert isinstance(res, tools.CommandPaths) 68 | 69 | 70 | def test_validate_armored_key(): 71 | specs = const.SupportedAlgos 72 | key_types = const.PQAKeyType.members() 73 | key_content = secrets.token_hex(32) 74 | 75 | for spec, key_type in itertools.product(specs, key_types): # type: const.AlgoSpec, const.PQAKeyType 76 | armor_name, key_type_name = spec.armor_name(), key_type.value 77 | header = f"-----BEGIN {armor_name} {key_type_name} KEY-----" 78 | footer = f"-----END {armor_name} {key_type_name} KEY-----" 79 | 80 | armored_key = f"{header}\n{key_content}\n{footer}" 81 | res = tools.validate_armored_key(armored_key, key_type, spec.type) 82 | assert res == armor_name 83 | 84 | with pytest.raises(SystemExit, match='1'): 85 | _armored_key = armored_key.replace(key_type_name, "KABOOM") 86 | tools.validate_armored_key(_armored_key, key_type, spec.type) 87 | 88 | with pytest.raises(SystemExit, match='1'): 89 | _key_type = const.PQAKeyType.PUBLIC.value 90 | if key_type == const.PQAKeyType.PUBLIC: 91 | _key_type = const.PQAKeyType.SECRET.value 92 | _armored_key = armored_key.replace(key_type_name, _key_type) 93 | tools.validate_armored_key(_armored_key, key_type, spec.type) 94 | 95 | with pytest.raises(SystemExit, match='1'): 96 | _armored_key = armored_key.replace(armor_name, "KABOOM") 97 | tools.validate_armored_key(_armored_key, key_type, spec.type) 98 | 99 | for _variable in [armor_name, key_type_name]: 100 | with pytest.raises(SystemExit, match='1'): 101 | _header = header.replace(_variable, "KABOOM") 102 | _armored_key = f"{_header}\n{key_content}\n{footer}" 103 | tools.validate_armored_key(_armored_key, key_type, spec.type) 104 | 105 | with pytest.raises(SystemExit, match='1'): 106 | _footer = footer.replace(_variable, "KABOOM") 107 | _armored_key = f"{header}\n{key_content}\n{_footer}" 108 | tools.validate_armored_key(_armored_key, key_type, spec.type) 109 | 110 | with pytest.raises(SystemExit, match='1'): 111 | _armored_key = armored_key.replace("BEGIN", "KABOOM") 112 | tools.validate_armored_key(_armored_key, key_type, spec.type) 113 | 114 | for bad_content in ['', ' ' * 100, *string.whitespace]: 115 | with pytest.raises(SystemExit, match='1'): 116 | _armored_key = armored_key.replace(key_content, bad_content) 117 | tools.validate_armored_key(_armored_key, key_type, spec.type) 118 | 119 | 120 | def test_get_pqa_class(): 121 | for armor_name in const.SupportedAlgos.armor_names(): 122 | cls = tools.get_pqa_class(armor_name) 123 | assert issubclass(cls, (BaseKEM, BaseDSS)) 124 | spec = cls.get_spec() 125 | assert isinstance(spec, const.AlgoSpec) 126 | assert spec.armor_name() == armor_name 127 | 128 | with pytest.raises(SystemExit, match='1'): 129 | tools.get_pqa_class("KABOOM") 130 | -------------------------------------------------------------------------------- /quantcrypt/internal/cli/commands/remove.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | import json 13 | from typer import Typer 14 | from itertools import product 15 | from quantcrypt.internal import utils, constants as const 16 | from quantcrypt.internal.cli import console, annotations as ats 17 | 18 | 19 | remove_app = Typer( 20 | name="remove", invoke_without_command=True, no_args_is_help=True, help=' '.join([ 21 | "Removes compiled PQA binaries from the library by name.", 22 | "Useful for reducing the size of software bundles when all PQC algorithms are not required.", 23 | "Usually called in a CI pipeline during the build process." 24 | ]) 25 | ) 26 | 27 | 28 | def remove_spec_variants( 29 | spec_variants: dict[const.AlgoSpec, list[const.PQAVariant]] 30 | ) -> tuple[dict, dict]: 31 | removed_variants: dict[const.AlgoSpec, list[const.PQAVariant]] = dict() 32 | already_removed: dict[const.AlgoSpec, list[const.PQAVariant]] = dict() 33 | bin_path = utils.search_upwards("bin") 34 | bin_contents = list(bin_path.iterdir()) 35 | 36 | for spec, variants in spec_variants.items(): # type: const.AlgoSpec, list[const.PQAVariant] 37 | for variant in variants: 38 | did_remove = False 39 | 40 | for item in bin_contents: 41 | if spec.module_name(variant) in item.name and item.exists(): 42 | item.unlink() 43 | x = removed_variants.get(spec, list()) 44 | x.append(variant) 45 | removed_variants[spec] = x 46 | did_remove = True 47 | 48 | if not did_remove: 49 | y = already_removed.get(spec, list()) 50 | y.append(variant) 51 | already_removed[spec] = y 52 | 53 | return removed_variants, already_removed 54 | 55 | 56 | def report_spec_variants( 57 | spec_variants: dict[const.AlgoSpec, list[const.PQAVariant]] 58 | ) -> None: 59 | armor_names = [s.armor_name() for s in spec_variants.keys()] 60 | longest_name_len = max(len(n) for n in armor_names) if armor_names else 0 61 | 62 | for spec, variants in spec_variants.items(): 63 | variants_fmt = json.dumps([v.value for v in variants]) 64 | arna_fmt = spec.armor_name().rjust(longest_name_len) 65 | console.styled_print(f"{arna_fmt}: {variants_fmt}") 66 | 67 | 68 | @remove_app.callback() 69 | def command_remove( 70 | algorithms: ats.RemoveAlgos, 71 | keep_algos: ats.KeepAlgos = False, 72 | only_ref: ats.OnlyRef = False, 73 | dry_run: ats.DryRun = False, 74 | non_interactive: ats.NonInteractive = False 75 | ) -> None: 76 | if only_ref and not keep_algos: 77 | console.raise_error("Cannot use --only-ref without --keep") 78 | 79 | chosen_algos = const.SupportedAlgos.filter(algorithms) 80 | if len(chosen_algos) != len(algorithms): 81 | algo_names = [s.armor_name() for s in chosen_algos] 82 | bad_names = [a for a in algorithms if a.upper() not in algo_names] 83 | if bad_names: # pragma: no branch 84 | console.raise_error( 85 | f"Unknown algorithm name(s): {json.dumps(bad_names)}. " + 86 | "Please choose algorithm names from the following list:\n" + 87 | ' | '.join(const.SupportedAlgos.armor_names()) 88 | ) 89 | 90 | console.notify_dry_run(dry_run) 91 | console.styled_print("QuantCrypt is about to remove compiled PQA binaries from itself.") 92 | 93 | if not non_interactive: 94 | console.ask_continue(exit_on_false=True) 95 | 96 | variants = const.PQAVariant.members() 97 | if only_ref: 98 | algorithms = [a.upper() for a in algorithms] 99 | algos = const.SupportedAlgos 100 | else: 101 | algos = const.SupportedAlgos.filter(algorithms, invert=keep_algos) 102 | 103 | to_remove: dict[const.AlgoSpec, list[const.PQAVariant]] = dict() 104 | for spec, variant in product(algos, variants): # type: const.AlgoSpec, const.PQAVariant 105 | if only_ref and spec.armor_name() in algorithms and variant == const.PQAVariant.REF: 106 | continue 107 | variants = to_remove.get(spec, list()) 108 | variants.append(variant) 109 | to_remove[spec] = variants 110 | 111 | if dry_run: 112 | console.styled_print("\nQuantCrypt would have removed the following algorithms and their variants:") 113 | report_spec_variants(to_remove) 114 | return 115 | 116 | removed_variants, already_removed = remove_spec_variants(to_remove) 117 | 118 | console.styled_print("\nSuccessfully removed binaries: ") 119 | report_spec_variants(removed_variants) 120 | 121 | console.styled_print("\nAlready removed binaries: ") 122 | report_spec_variants(already_removed) 123 | 124 | print() 125 | console.print_success() 126 | -------------------------------------------------------------------------------- /tests/test_constants.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | import requests 13 | from pathlib import Path 14 | from quantcrypt.internal import constants as const 15 | 16 | 17 | def test_extended_enum(): 18 | class TestEnum(const.ExtendedEnum): 19 | FIRST = "asdfg" 20 | SECOND = "qwerty" 21 | 22 | assert TestEnum.members() == [ 23 | TestEnum.FIRST, 24 | TestEnum.SECOND, 25 | ] 26 | assert TestEnum.values() == [ 27 | TestEnum.FIRST.value, 28 | TestEnum.SECOND.value, 29 | ] 30 | assert TestEnum.FIRST.value == "asdfg" 31 | assert TestEnum.SECOND.value == "qwerty" 32 | 33 | 34 | def test_pqa_variant(): 35 | assert const.PQAVariant.members() == [ 36 | const.PQAVariant.REF, 37 | const.PQAVariant.OPT_AMD, 38 | const.PQAVariant.OPT_ARM 39 | ] 40 | assert const.PQAVariant.values() == [ 41 | const.PQAVariant.REF.value, 42 | const.PQAVariant.OPT_AMD.value, 43 | const.PQAVariant.OPT_ARM.value 44 | ] 45 | assert const.PQAVariant.REF.value == "clean" 46 | assert const.PQAVariant.OPT_AMD.value == "avx2" 47 | assert const.PQAVariant.OPT_ARM.value == "aarch64" 48 | 49 | 50 | def test_pqa_type(): 51 | assert const.PQAType.members() == [ 52 | const.PQAType.KEM, 53 | const.PQAType.DSS, 54 | const.PQAType._COM 55 | ] 56 | assert const.PQAType.values() == [ 57 | const.PQAType.KEM.value, 58 | const.PQAType.DSS.value, 59 | const.PQAType._COM.value 60 | ] 61 | assert const.PQAType.KEM.value == "crypto_kem" 62 | assert const.PQAType.DSS.value == "crypto_sign" 63 | assert const.PQAType._COM.value == "common" 64 | 65 | 66 | def test_pqa_key_type(): 67 | assert const.PQAKeyType.members() == [ 68 | const.PQAKeyType.PUBLIC, 69 | const.PQAKeyType.SECRET 70 | ] 71 | assert const.PQAKeyType.values() == [ 72 | const.PQAKeyType.PUBLIC.value, 73 | const.PQAKeyType.SECRET.value 74 | ] 75 | assert const.PQAKeyType.PUBLIC.value == "PUBLIC" 76 | assert const.PQAKeyType.SECRET.value == "SECRET" 77 | 78 | 79 | def test_algo_spec(): 80 | spec_types = [ 81 | (const.AlgoSpec.KEM, const.PQAType.KEM), 82 | (const.AlgoSpec.DSS, const.PQAType.DSS), 83 | ] 84 | for algo_spec, pqa_type in spec_types: 85 | class_name, pqclean_name = "ASDFG_1234", "as-dfg-1234" 86 | spec: const.AlgoSpec = algo_spec( 87 | class_name=class_name, 88 | pqclean_name=pqclean_name 89 | ) 90 | assert isinstance(spec, const.AlgoSpec) 91 | assert spec.src_subdir == Path(pqa_type.value, pqclean_name) 92 | assert spec.pqclean_name == pqclean_name 93 | assert spec.class_name == class_name 94 | assert spec.type == pqa_type 95 | assert spec.armor_name() == "ASDFG1234" 96 | 97 | assert spec.cdef_name(const.PQAVariant.REF) == "PQCLEAN_ASDFG1234_CLEAN" 98 | assert spec.cdef_name(const.PQAVariant.OPT_AMD) == "PQCLEAN_ASDFG1234_AVX2" 99 | assert spec.cdef_name(const.PQAVariant.OPT_ARM) == "PQCLEAN_ASDFG1234_AARCH64" 100 | 101 | assert spec.module_name(const.PQAVariant.REF) == "as_dfg_1234_clean" 102 | assert spec.module_name(const.PQAVariant.OPT_AMD) == "as_dfg_1234_avx2" 103 | assert spec.module_name(const.PQAVariant.OPT_ARM) == "as_dfg_1234_aarch64" 104 | 105 | 106 | def test_supported_algos(): 107 | assert isinstance(const.SupportedAlgos, list) 108 | 109 | for item in const.SupportedAlgos: 110 | assert isinstance(item, const.AlgoSpec) 111 | 112 | for item in const.SupportedAlgos.pqclean_names(): 113 | assert isinstance(item, str) 114 | 115 | for item in const.SupportedAlgos.armor_names(): 116 | assert isinstance(item, str) 117 | 118 | specs = const.SupportedAlgos.filter(["asdfg1234"]) 119 | assert specs == [] 120 | 121 | armor_names = ["MLKEM1024", "MLDSA87"] 122 | specs = const.SupportedAlgos.filter(armor_names) 123 | assert isinstance(specs, list) and len(specs) == 2 124 | 125 | for spec in specs: 126 | assert spec.armor_name() in armor_names 127 | assert isinstance(spec, const.AlgoSpec) 128 | 129 | armor_names = ["MLKEM1024", "MLDSA87"] 130 | specs = const.SupportedAlgos.filter(armor_names, invert=True) 131 | for spec in specs: 132 | assert spec.armor_name() not in armor_names 133 | 134 | for pqa_type in const.PQAType.members(): # type: const.PQAType 135 | for item in const.SupportedAlgos.armor_names(pqa_type): 136 | assert isinstance(item, str) 137 | 138 | 139 | def test_pqclean_repo_archive_url(): 140 | url = const.PQCleanRepoArchiveURL 141 | res = requests.head(url) 142 | if res.status_code == 302: 143 | url = res.headers["location"] 144 | res = requests.head(url) 145 | assert res.status_code == 200 146 | assert res.headers["content-type"] == "application/zip" 147 | -------------------------------------------------------------------------------- /tests/test_pqa/conftest.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | import os 13 | import pytest 14 | from pathlib import Path 15 | from abc import ABC, abstractmethod 16 | from typing import Type, cast 17 | from pydantic import ValidationError 18 | from quantcrypt.internal import errors, pqclean 19 | from quantcrypt.internal import constants as const 20 | from quantcrypt.internal.pqa.base_dss import BaseDSS 21 | from quantcrypt.internal.pqa.base_kem import BaseKEM 22 | 23 | 24 | class BaseAlgorithmTester(ABC): 25 | @classmethod 26 | @abstractmethod 27 | def run_tests(cls, alt_tmp_path: Path, pqa_class: Type[BaseDSS | BaseKEM]) -> None: ... 28 | 29 | @staticmethod 30 | def notify(pqa_instance: BaseDSS | BaseKEM, msg: str) -> None: 31 | name = pqa_instance.armor_name() 32 | variant = pqa_instance.variant.value 33 | print(f"{msg} of {variant} {name}") 34 | 35 | @staticmethod 36 | def invalid_keys(valid_key: bytes) -> list[bytes]: 37 | str_key = cast(bytes, valid_key.decode(errors="replace")) 38 | return [str_key, valid_key[:-1], valid_key + b'0'] 39 | 40 | @staticmethod 41 | def invalid_messages(valid_message: bytes) -> list[bytes]: 42 | str_msg = cast(bytes, valid_message.decode(errors="replace")) 43 | return [str_msg, b''] 44 | 45 | @staticmethod 46 | def invalid_signatures(valid_signature: bytes, max_size: int) -> list[bytes]: 47 | str_sig = cast(bytes, valid_signature.decode(errors="replace")) 48 | extra = b'0' * (max_size - len(valid_signature) + 1) 49 | return [str_sig, valid_signature + extra] 50 | 51 | @staticmethod 52 | def invalid_ciphertexts(valid_ciphertext: bytes) -> list[bytes]: 53 | str_txt = cast(bytes, valid_ciphertext.decode(errors="replace")) 54 | return [str_txt, valid_ciphertext[:-1], valid_ciphertext + b'0'] 55 | 56 | @staticmethod 57 | def get_pqa_instances(pqa_class: Type[BaseDSS | BaseKEM]) -> list[BaseDSS | BaseKEM]: 58 | instances: list[BaseDSS | BaseKEM] = [] 59 | spec = pqa_class.get_spec() 60 | for variant in const.PQAVariant.members(): # type: const.PQAVariant 61 | if "CODECOV" in os.environ and variant != const.PQAVariant.REF: 62 | continue 63 | path, flags = pqclean.check_platform_support(spec, variant) 64 | if path is not None and flags is not None: 65 | inst = pqa_class(variant, allow_fallback=False) 66 | instances.append(inst) 67 | return instances 68 | 69 | @classmethod 70 | def run_armor_success_tests(cls, pqa_instance: BaseDSS | BaseKEM) -> None: 71 | cls.notify(pqa_instance, "Testing armor success") 72 | public_key, secret_key = pqa_instance.keygen() 73 | 74 | apk = pqa_instance.armor(public_key) 75 | assert apk.startswith("-----BEGIN") 76 | assert apk.endswith("PUBLIC KEY-----") 77 | 78 | ask = pqa_instance.armor(secret_key) 79 | assert ask.startswith("-----BEGIN") 80 | assert ask.endswith("SECRET KEY-----") 81 | 82 | pkb = pqa_instance.dearmor(apk) 83 | assert pkb == public_key 84 | 85 | skb = pqa_instance.dearmor(ask) 86 | assert skb == secret_key 87 | 88 | 89 | @classmethod 90 | def run_armor_failure_tests(cls, pqa_instance: BaseDSS | BaseKEM) -> None: 91 | cls.notify(pqa_instance, "Testing armor failure") 92 | public_key, secret_key = pqa_instance.keygen() 93 | 94 | for key in [str, int, float, list, dict, tuple, set]: 95 | with pytest.raises(ValidationError): 96 | pqa_instance.armor(cast(key(), bytes)) 97 | 98 | if "SPHINCS" in pqa_instance.armor_name(): 99 | return # key size parameters are broken in C code 100 | 101 | for key in cls.invalid_keys(public_key): 102 | with pytest.raises(errors.PQAKeyArmorError): 103 | pqa_instance.armor(key) 104 | 105 | for key in cls.invalid_keys(secret_key): 106 | with pytest.raises(errors.PQAKeyArmorError): 107 | pqa_instance.armor(key) 108 | 109 | @classmethod 110 | def run_dearmor_failure_tests(cls, pqa_instance: BaseDSS | BaseKEM) -> None: 111 | cls.notify(pqa_instance, "Testing dearmor failure") 112 | public_key, secret_key = pqa_instance.keygen() 113 | 114 | for key in [str, int, float, list, dict, tuple, set]: 115 | with pytest.raises(ValidationError): 116 | pqa_instance.dearmor(cast(key(), bytes)) 117 | 118 | if "SPHINCS" in pqa_instance.armor_name(): 119 | return # key size parameters are broken in C code 120 | 121 | def _reuse_tests(data: list[str]): 122 | center = len(data) // 2 123 | 124 | with pytest.raises(errors.PQAKeyArmorError): 125 | copy = data.copy() 126 | copy.pop(center) 127 | pqa_instance.dearmor('\n'.join(copy)) 128 | 129 | with pytest.raises(errors.PQAKeyArmorError): 130 | copy = data.copy() 131 | copy.insert(1, data[1]) 132 | pqa_instance.dearmor('\n'.join(copy)) 133 | 134 | with pytest.raises(errors.PQAKeyArmorError): 135 | copy = data.copy() 136 | line = copy.pop(center)[:-1] + '!' 137 | copy.insert(center, line) 138 | pqa_instance.dearmor('\n'.join(copy)) 139 | 140 | with pytest.raises(errors.PQAKeyArmorError): 141 | pqa_instance.dearmor("") 142 | 143 | apk = pqa_instance.armor(public_key).split('\n') 144 | _reuse_tests(apk) 145 | 146 | ask = pqa_instance.armor(secret_key).split('\n') 147 | _reuse_tests(ask) 148 | -------------------------------------------------------------------------------- /tests/test_cipher/test_krypton_kem.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | import os 13 | import timeit 14 | import pytest 15 | from pathlib import Path 16 | from dotmap import DotMap 17 | from typing import Callable 18 | from quantcrypt.cipher import KryptonKEM, ChunkSize 19 | from quantcrypt.kdf import KDFParams, MemCost 20 | from quantcrypt.kem import MLKEM_512 21 | 22 | 23 | def test_krypton_kem_attributes(): 24 | krypton = KryptonKEM(MLKEM_512) 25 | 26 | assert hasattr(krypton, "encrypt") 27 | assert hasattr(krypton, "decrypt_to_file") 28 | assert hasattr(krypton, "decrypt_to_memory") 29 | 30 | assert isinstance(getattr(krypton, "encrypt"), Callable) 31 | assert isinstance(getattr(krypton, "decrypt_to_file"), Callable) 32 | assert isinstance(getattr(krypton, "decrypt_to_memory"), Callable) 33 | 34 | 35 | def test_krypton_kem_enc_dec(krypton_file_helpers: DotMap): 36 | kfh = krypton_file_helpers 37 | 38 | kem = MLKEM_512() 39 | pk, sk = kem.keygen() 40 | krypton = KryptonKEM(MLKEM_512, KDFParams( 41 | memory_cost=MemCost.MB(32), 42 | parallelism=8, 43 | time_cost=1, 44 | hash_len=64, 45 | salt_len=32 46 | )) 47 | 48 | krypton.encrypt(pk, kfh.pt_file, kfh.ct_file) 49 | krypton.decrypt_to_file(sk, kfh.ct_file, kfh.pt2_file) 50 | 51 | with kfh.pt2_file.open("rb") as file: 52 | pt2 = file.read() 53 | with kfh.ct_file.open("rb") as file: 54 | ct = file.read() 55 | 56 | assert pt2 == kfh.orig_pt 57 | assert ct != kfh.orig_pt 58 | 59 | 60 | def test_krypton_kem_output_file(krypton_file_helpers: DotMap): 61 | kfh = krypton_file_helpers 62 | os.chdir(kfh.tmp_path) 63 | 64 | kem = MLKEM_512() 65 | pk, sk = kem.keygen() 66 | krypton = KryptonKEM(MLKEM_512) 67 | setattr(krypton, "_testing", True) 68 | 69 | ct_file = kfh.pt_file.with_suffix(".kptn") 70 | 71 | krypton.encrypt(pk, kfh.pt_file) 72 | kfh.pt_file.unlink() 73 | krypton.decrypt_to_file(sk, ct_file) 74 | krypton.decrypt_to_file(sk, ct_file, kfh.pt2_file.name) 75 | 76 | with ct_file.open("rb") as file: 77 | ct = file.read() 78 | with kfh.pt_file.open("rb") as file: 79 | pt1 = file.read() 80 | with kfh.pt2_file.open("rb") as file: 81 | pt2 = file.read() 82 | 83 | assert ct != kfh.orig_pt 84 | assert pt1 == kfh.orig_pt 85 | assert pt2 == kfh.orig_pt 86 | 87 | 88 | def test_krypton_kem_enc_dec_callback(krypton_file_helpers: DotMap): 89 | kfh = krypton_file_helpers 90 | 91 | kem = MLKEM_512() 92 | pk, sk = kem.keygen() 93 | krypton = KryptonKEM(MLKEM_512, callback=kfh.callback) 94 | setattr(krypton, "_testing", True) 95 | 96 | krypton.encrypt(pk, kfh.pt_file, kfh.ct_file) 97 | assert sum(kfh.counter) == 4 98 | krypton.decrypt_to_file(sk, kfh.ct_file, kfh.pt2_file) 99 | assert sum(kfh.counter) == 8 100 | 101 | 102 | def test_krypton_kem_enc_dec_into_memory(krypton_file_helpers: DotMap): 103 | kfh = krypton_file_helpers 104 | 105 | kem = MLKEM_512() 106 | pk, sk = kem.keygen() 107 | krypton = KryptonKEM(MLKEM_512) 108 | setattr(krypton, "_testing", True) 109 | 110 | krypton.encrypt(pk, kfh.pt_file, kfh.ct_file) 111 | pt2 = krypton.decrypt_to_memory(sk, kfh.ct_file) 112 | assert pt2 == kfh.orig_pt 113 | 114 | 115 | def test_krypton_kem_enc_dec_chunk_size_override(krypton_file_helpers: DotMap): 116 | kfh = krypton_file_helpers 117 | 118 | kem = MLKEM_512() 119 | pk, sk = kem.keygen() 120 | krypton = KryptonKEM(MLKEM_512, chunk_size=ChunkSize.KB(1), callback=kfh.callback) 121 | setattr(krypton, "_testing", True) 122 | 123 | krypton.encrypt(pk, kfh.pt_file, kfh.ct_file) 124 | assert sum(kfh.counter) == 16 125 | pt2 = krypton.decrypt_to_memory(sk, kfh.ct_file) 126 | assert sum(kfh.counter) == 32 127 | assert pt2 == kfh.orig_pt 128 | 129 | 130 | def test_krypton_kem_enc_dec_errors(krypton_file_helpers: DotMap): 131 | kfh = krypton_file_helpers 132 | 133 | kem = MLKEM_512() 134 | pk, sk = kem.keygen() 135 | krypton = KryptonKEM(MLKEM_512) 136 | 137 | kfh.ct_file.touch() 138 | kfh.pt2_file.touch() 139 | 140 | with pytest.raises(FileNotFoundError): 141 | krypton.encrypt(pk, Path("asdfg"), kfh.ct_file) 142 | with pytest.raises(FileNotFoundError): 143 | krypton.decrypt_to_file(sk, Path("asdfg"), kfh.pt2_file) 144 | with pytest.raises(FileNotFoundError): 145 | krypton.decrypt_to_memory(sk, Path("asdfg")) 146 | 147 | 148 | def test_krypton_kem_argon2_delay(krypton_file_helpers: DotMap): 149 | kfh = krypton_file_helpers 150 | 151 | kem = MLKEM_512() 152 | pk, sk = kem.keygen() 153 | krypton = KryptonKEM(MLKEM_512) 154 | 155 | def test(): 156 | krypton.encrypt(pk, kfh.pt_file, kfh.ct_file) 157 | 158 | def test2(): 159 | krypton.decrypt_to_file(sk, kfh.ct_file, kfh.pt2_file) 160 | 161 | def test3(): 162 | krypton.decrypt_to_memory(sk, kfh.ct_file) 163 | 164 | assert timeit.timeit(test, number=1) > 0.2 165 | assert timeit.timeit(test2, number=1) > 0.2 166 | assert timeit.timeit(test3, number=1) > 0.2 167 | 168 | 169 | def test_krypton_kem_armored_keys(krypton_file_helpers: DotMap): 170 | kfh = krypton_file_helpers 171 | 172 | kem = MLKEM_512() 173 | pk, sk = kem.keygen() 174 | 175 | krypton = KryptonKEM(MLKEM_512, KDFParams( 176 | memory_cost=MemCost.MB(32), 177 | parallelism=8, 178 | time_cost=1, 179 | hash_len=64, 180 | salt_len=32 181 | )) 182 | 183 | krypton.encrypt(kem.armor(pk), kfh.pt_file, kfh.ct_file) 184 | pt = krypton.decrypt_to_memory(kem.armor(sk), kfh.ct_file) 185 | assert pt == kfh.orig_pt 186 | -------------------------------------------------------------------------------- /quantcrypt/internal/pqa/common.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | import re 13 | import string 14 | import platform 15 | import importlib 16 | from cffi import FFI 17 | from abc import ABC, abstractmethod 18 | from types import ModuleType 19 | from typing import Literal, Type 20 | from functools import cache 21 | from quantcrypt.internal import utils, errors, constants as const 22 | 23 | 24 | __all__ = ["BasePQAParamSizes", "BasePQAlgorithm"] 25 | 26 | 27 | class BasePQAParamSizes: 28 | def __init__(self, lib: ModuleType, cdef_name: str): 29 | self.sk_size = getattr(lib, f"{cdef_name}_CRYPTO_SECRETKEYBYTES") 30 | self.pk_size = getattr(lib, f"{cdef_name}_CRYPTO_PUBLICKEYBYTES") 31 | 32 | 33 | class BasePQAlgorithm(ABC): 34 | _lib: ModuleType 35 | variant: const.PQAVariant 36 | 37 | @property 38 | def spec(self) -> const.AlgoSpec: 39 | return self.get_spec() 40 | 41 | @classmethod 42 | def get_spec(cls) -> const.AlgoSpec: # pragma: no cover 43 | for spec in const.SupportedAlgos: 44 | if spec.class_name == cls.__name__: 45 | return spec 46 | raise errors.PQAUnsupportedAlgoError(cls.__name__) 47 | 48 | @classmethod 49 | def armor_name(cls) -> str: 50 | return cls.__name__.replace('_', '').upper() 51 | 52 | @abstractmethod 53 | def keygen(self) -> tuple[bytes, bytes]: ... 54 | 55 | @property 56 | @abstractmethod 57 | def param_sizes(self) -> BasePQAParamSizes: ... 58 | 59 | @property 60 | def _cdef_name(self) -> str: 61 | return self.spec.cdef_name(self.variant) 62 | 63 | @property 64 | def _auto_select_variant(self) -> const.PQAVariant: # pragma: no cover 65 | opsys = platform.machine().lower() 66 | if opsys in const.ARMArches: 67 | return const.PQAVariant.OPT_ARM 68 | elif opsys in const.AMDArches: 69 | return const.PQAVariant.OPT_AMD 70 | return const.PQAVariant.REF 71 | 72 | @cache 73 | def _import(self, variant: const.PQAVariant) -> ModuleType: 74 | module_name = self.spec.module_name(variant) 75 | module_path = f"quantcrypt.internal.bin.{module_name}" 76 | return importlib.import_module(module_path).lib 77 | 78 | def __init__(self, variant: const.PQAVariant | None, allow_fallback: bool) -> None: 79 | self.variant = variant or self._auto_select_variant 80 | try: 81 | self._lib = self._import(self.variant) 82 | except ImportError: # pragma: no cover 83 | if allow_fallback and self.variant != const.PQAVariant.REF: 84 | self.variant = const.PQAVariant.REF 85 | try: 86 | self._lib = self._import(self.variant) 87 | return 88 | except ImportError: 89 | pass 90 | raise errors.PQAImportError(self.spec, self.variant) 91 | 92 | def _keygen( 93 | self, 94 | algo_type: Literal["kem", "sign"], 95 | error_cls: Type[errors.PQAError] 96 | ) -> tuple[bytes, bytes]: 97 | ffi, params = FFI(), self.param_sizes 98 | public_key = ffi.new(f"uint8_t [{params.pk_size}]") 99 | secret_key = ffi.new(f"uint8_t [{params.sk_size}]") 100 | 101 | func_name = f"_crypto_{algo_type}_keypair" 102 | func = getattr(self._lib, self._cdef_name + func_name) 103 | if func(public_key, secret_key) != 0: # pragma: no cover 104 | raise error_cls 105 | 106 | pk = ffi.buffer(public_key, params.pk_size) 107 | sk = ffi.buffer(secret_key, params.sk_size) 108 | return bytes(pk), bytes(sk) 109 | 110 | @utils.input_validator() 111 | def armor(self, key_bytes: bytes) -> str: 112 | """ 113 | :param key_bytes: The key bytes that were generated by a PQA class, 114 | that are going to be armored into a base64-encoded ASCII text. 115 | :return: ASCII armored key string 116 | :raises - pydantic.ValidationError: On invalid input. 117 | :raises - errors.PQAKeyArmorError: If armoring fails for any reason. 118 | """ 119 | params = self.param_sizes 120 | match len(key_bytes): 121 | case params.sk_size: 122 | key_type = "SECRET" 123 | case params.pk_size: 124 | key_type = "PUBLIC" 125 | case _: 126 | raise errors.PQAKeyArmorError("armor") 127 | key_str = utils.b64(key_bytes) 128 | max_line_length = 64 129 | lines = [ 130 | key_str[i:i + max_line_length] 131 | for i in range(0, len(key_str), max_line_length) 132 | ] 133 | algo_name = self.armor_name() 134 | header = f"-----BEGIN {algo_name} {key_type} KEY-----\n" 135 | footer = f"\n-----END {algo_name} {key_type} KEY-----" 136 | return header + '\n'.join(lines) + footer 137 | 138 | @utils.input_validator() 139 | def dearmor(self, armored_key: str) -> bytes: 140 | """ 141 | :param armored_key: An ASCII armored PQA key, armored by 142 | the same PQA class that is being used to dearmor the key. 143 | :return: Bytes of the de-armored key 144 | :raises - pydantic.ValidationError: On invalid input. 145 | :raises - errors.PQAKeyArmorError: If dearmoring fails for any reason. 146 | """ 147 | dearmor_error = errors.PQAKeyArmorError("dearmor") 148 | algo_name = self.armor_name() 149 | key_data: str = '' 150 | 151 | for key_type in ["PUBLIC", "SECRET"]: 152 | header_pattern = rf"^-----BEGIN {algo_name} {key_type} KEY-----\n" 153 | footer_pattern = rf"\n-----END {algo_name} {key_type} KEY-----$" 154 | full_pattern = header_pattern + r"(.+)" + footer_pattern 155 | if match := re.match(full_pattern, armored_key, re.DOTALL): 156 | key_data = match.group(1) or '' 157 | for char in string.whitespace: 158 | key_data = key_data.replace(char, '') 159 | break 160 | 161 | if not key_data: 162 | raise dearmor_error 163 | 164 | try: 165 | key_bytes = utils.b64(key_data) 166 | except errors.InvalidArgsError: 167 | raise dearmor_error 168 | 169 | expected_size = dict( 170 | PUBLIC=self.param_sizes.pk_size, 171 | SECRET=self.param_sizes.sk_size 172 | )[key_type] 173 | if len(key_bytes) != expected_size: 174 | raise dearmor_error 175 | 176 | return key_bytes 177 | -------------------------------------------------------------------------------- /quantcrypt/internal/constants.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | from __future__ import annotations 13 | from enum import Enum 14 | from typing import Any 15 | from pathlib import Path 16 | from functools import cache 17 | from itertools import product 18 | from dataclasses import dataclass 19 | 20 | 21 | __all__ = [ 22 | "ExtendedEnum", 23 | "PQAVariant", 24 | "PQAType", 25 | "PQAKeyType", 26 | "AlgoSpec", 27 | "SupportedAlgos", 28 | "KDFContext", 29 | "KryptonFileSuffix", 30 | "AMDArches", 31 | "ARMArches", 32 | "PQCleanRepoArchiveURL", 33 | "ExcludedCombinations" 34 | ] 35 | 36 | 37 | class ExtendedEnum(Enum): 38 | @classmethod 39 | @cache 40 | def members(cls) -> list: 41 | return list(cls.__members__.values()) 42 | 43 | @classmethod 44 | @cache 45 | def values(cls) -> list[str]: 46 | return [member.value for member in cls.members()] 47 | 48 | 49 | class PQAVariant(ExtendedEnum): 50 | """ 51 | Available binaries of algorithms: 52 | * REF - Clean reference binaries for the x86_64 architecture. 53 | * OPT - Speed-optimized binaries for the x86_64 architecture. 54 | * ARM - Binaries for the aarch64 architecture. 55 | """ 56 | REF = "clean" 57 | OPT_AMD = "avx2" 58 | OPT_ARM = "aarch64" 59 | 60 | 61 | class PQAType(ExtendedEnum): 62 | """ 63 | Available types of PQ algorithms: 64 | * KEM - Key Encapsulation Mechanism 65 | * DSS - Digital Signature Scheme 66 | """ 67 | KEM = "crypto_kem" 68 | DSS = "crypto_sign" 69 | _COM = "common" 70 | 71 | 72 | class PQAKeyType(ExtendedEnum): 73 | """ 74 | Available types of PQA keys: 75 | * PUBLIC - Public key 76 | * SECRET - Secret key 77 | """ 78 | PUBLIC = "PUBLIC" 79 | SECRET = "SECRET" 80 | 81 | 82 | @dataclass(frozen=True) 83 | class AlgoSpec: 84 | type: PQAType 85 | src_subdir: Path 86 | pqclean_name: str 87 | class_name: str 88 | 89 | @classmethod 90 | def KEM(cls, class_name: str, pqclean_name: str) -> AlgoSpec: # NOSONAR 91 | src_subdir = Path(PQAType.KEM.value, pqclean_name) 92 | return cls( 93 | type=PQAType.KEM, 94 | src_subdir=src_subdir, 95 | pqclean_name=pqclean_name, 96 | class_name=class_name 97 | ) 98 | 99 | @classmethod 100 | def DSS(cls, class_name: str, pqclean_name: str) -> AlgoSpec: # NOSONAR 101 | src_subdir = Path(PQAType.DSS.value, pqclean_name) 102 | return cls( 103 | type=PQAType.DSS, 104 | src_subdir=src_subdir, 105 | pqclean_name=pqclean_name, 106 | class_name=class_name 107 | ) 108 | 109 | @cache 110 | def cdef_name(self, variant: PQAVariant) -> str: 111 | name = self.pqclean_name.replace('-', '') 112 | return f"PQCLEAN_{name}_{variant.value}".upper() 113 | 114 | @cache 115 | def module_name(self, variant: PQAVariant) -> str: 116 | name = self.pqclean_name.replace('-', '_') 117 | return f"{name}_{variant.value}".lower() 118 | 119 | @cache 120 | def armor_name(self) -> str: 121 | name = self.class_name.replace('_', '') 122 | return name.upper() # case safety 123 | 124 | 125 | class AlgoSpecsList(list): 126 | def pqclean_names(self: list[AlgoSpec]) -> list[str]: 127 | return [spec.pqclean_name for spec in self] 128 | 129 | def armor_names(self: list[AlgoSpec], pqa_type: PQAType | None = None) -> list[str]: 130 | return [ 131 | spec.armor_name() for spec in self if 132 | not pqa_type or pqa_type == spec.type 133 | ] 134 | 135 | def filter(self, armor_names: list[str], invert: bool = False) -> list[AlgoSpec]: 136 | armor_names = [n.upper() for n in armor_names] 137 | return [ 138 | spec for spec, name in product(self, armor_names) 139 | if (not invert and spec.armor_name() == name) 140 | or (invert and spec.armor_name() not in armor_names) 141 | ] 142 | 143 | 144 | SupportedAlgos: AlgoSpecsList[AlgoSpec] = AlgoSpecsList([ 145 | AlgoSpec.KEM( 146 | class_name="MLKEM_512", 147 | pqclean_name="ml-kem-512" 148 | ), 149 | AlgoSpec.KEM( 150 | class_name="MLKEM_768", 151 | pqclean_name="ml-kem-768" 152 | ), 153 | AlgoSpec.KEM( 154 | class_name="MLKEM_1024", 155 | pqclean_name="ml-kem-1024" 156 | ), 157 | AlgoSpec.DSS( 158 | class_name="MLDSA_44", 159 | pqclean_name="ml-dsa-44" 160 | ), 161 | AlgoSpec.DSS( 162 | class_name="MLDSA_65", 163 | pqclean_name="ml-dsa-65" 164 | ), 165 | AlgoSpec.DSS( 166 | class_name="MLDSA_87", 167 | pqclean_name="ml-dsa-87" 168 | ), 169 | AlgoSpec.DSS( 170 | class_name="FALCON_512", 171 | pqclean_name="falcon-512" 172 | ), 173 | AlgoSpec.DSS( 174 | class_name="FALCON_1024", 175 | pqclean_name="falcon-1024" 176 | ), 177 | AlgoSpec.DSS( 178 | class_name="FAST_SPHINCS", 179 | pqclean_name="sphincs-shake-256f-simple" 180 | ), 181 | AlgoSpec.DSS( 182 | class_name="SMALL_SPHINCS", 183 | pqclean_name="sphincs-shake-256s-simple" 184 | ), 185 | ]) 186 | 187 | 188 | KDFContext = b"quantcrypt" 189 | SubprocTag = "<--quantcrypt-->" 190 | KryptonFileSuffix = ".kptn" 191 | SignatureFileSuffix = ".sig" 192 | AMDArches = ["x86_64", "amd64", "x86-64", "x64", "intel64"] 193 | ARMArches = ["arm_8", "arm64", "aarch64", "armv8", "armv8-a"] 194 | PQCleanRepoArchiveURL = "https://github.com/PQClean/PQClean/archive/448c71a8f590343e681d0d0cec94f29947b0ff18.zip" 195 | ExcludedCombinations: list[tuple[Any, Any]] = [ 196 | ("darwin", PQAVariant.OPT_AMD), 197 | ("darwin", PQAVariant.OPT_ARM) 198 | ] 199 | -------------------------------------------------------------------------------- /tests/test_kdf/test_argon2.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | import timeit 13 | import pytest 14 | from typing import Type, Callable 15 | from pydantic import ValidationError 16 | from quantcrypt.kdf import Argon2 17 | from quantcrypt.utils import KDFParams, MemCost 18 | from quantcrypt.internal import errors 19 | from quantcrypt.internal import utils 20 | 21 | 22 | @pytest.fixture(name="good_pw", scope="module") 23 | def fixture_good_pw() -> str: 24 | return "A8c7hBBTnVC90kP5AIe2" 25 | 26 | 27 | @pytest.fixture(name="test_context", scope="module") 28 | def fixture_test_context() -> Callable: 29 | def closure(kdf_cls: Type, *_, **__): 30 | class Context: 31 | def __enter__(self): 32 | setattr(kdf_cls, "_testing", True) 33 | 34 | def __exit__(self, exc_type, exc_value, traceback): 35 | setattr(kdf_cls, "_testing", False) 36 | 37 | return Context() 38 | return closure 39 | 40 | 41 | def test_argon2params_good_values(): 42 | KDFParams( 43 | memory_cost=MemCost.MB(32), 44 | parallelism=1, 45 | time_cost=1, 46 | hash_len=64, 47 | salt_len=16 48 | ) 49 | 50 | 51 | def test_argon2params_bad_parallelism(): 52 | with pytest.raises(ValidationError): 53 | KDFParams( 54 | memory_cost=MemCost.MB(32), 55 | parallelism=0, # less than 1 56 | time_cost=1, 57 | hash_len=64, 58 | salt_len=16 59 | ) 60 | 61 | 62 | def test_argon2params_bad_time_cost(): 63 | with pytest.raises(ValidationError): 64 | KDFParams( 65 | memory_cost=MemCost.MB(32), 66 | parallelism=1, 67 | time_cost=0, # less than 1 68 | hash_len=64, 69 | salt_len=16 70 | ) 71 | 72 | 73 | def test_argon2params_too_short_hash_len(): 74 | with pytest.raises(ValidationError): 75 | KDFParams( 76 | memory_cost=MemCost.MB(32), 77 | parallelism=1, 78 | time_cost=1, 79 | hash_len=15, # less than 16 80 | salt_len=16 81 | ) 82 | 83 | 84 | def test_argon2params_too_long_hash_len(): 85 | with pytest.raises(ValidationError): 86 | KDFParams( 87 | memory_cost=MemCost.MB(32), 88 | parallelism=1, 89 | time_cost=1, 90 | hash_len=65, # more than 64 91 | salt_len=16 92 | ) 93 | 94 | 95 | def test_argon2params_too_short_salt_len(): 96 | with pytest.raises(ValidationError): 97 | KDFParams( 98 | memory_cost=MemCost.MB(32), 99 | parallelism=1, 100 | time_cost=1, 101 | hash_len=64, 102 | salt_len=15 # less than 16 103 | ) 104 | 105 | 106 | def test_argon2params_too_long_salt_len(): 107 | with pytest.raises(ValidationError): 108 | KDFParams( 109 | memory_cost=MemCost.MB(32), 110 | parallelism=1, 111 | time_cost=1, 112 | hash_len=64, 113 | salt_len=65 # more than 64 114 | ) 115 | 116 | 117 | def test_argon2hash_success(good_pw: str, test_context: Callable): 118 | with test_context(Argon2.Hash): 119 | kdf1 = Argon2.Hash(good_pw) 120 | assert kdf1.rehashed is False 121 | assert kdf1.verified is False 122 | 123 | kdf2 = Argon2.Hash(good_pw, kdf1.public_hash) 124 | assert kdf2.rehashed is False 125 | assert kdf2.verified is True 126 | 127 | 128 | def test_argon2hash_errors(good_pw: str, test_context: Callable): 129 | with test_context(Argon2.Hash): 130 | kdf = Argon2.Hash(good_pw) 131 | 132 | with pytest.raises(errors.KDFVerificationError): 133 | Argon2.Hash(good_pw[::-1], kdf.public_hash) 134 | 135 | with pytest.raises(errors.KDFInvalidHashError): 136 | Argon2.Hash(good_pw, kdf.public_hash[::-1]) 137 | 138 | with pytest.raises(errors.KDFWeakPasswordError): 139 | Argon2.Hash('a' * 7) 140 | 141 | 142 | def test_argon2hash_overrides(good_pw: str): 143 | ovr_s = KDFParams( 144 | memory_cost=MemCost.MB(32), # smaller than ovr2 145 | parallelism=8, 146 | time_cost=1, 147 | hash_len=16, 148 | salt_len=16 149 | ) 150 | ovr_ref = KDFParams( 151 | memory_cost=MemCost.MB(64), # Reference 152 | parallelism=8, 153 | time_cost=1, 154 | hash_len=16, 155 | salt_len=16 156 | ) 157 | ovr_l = KDFParams( 158 | memory_cost=MemCost.MB(128), # larger than ovr2 159 | parallelism=8, 160 | time_cost=1, 161 | hash_len=16, 162 | salt_len=16 163 | ) 164 | kdf_ref = Argon2.Hash(good_pw, params=ovr_ref) 165 | 166 | kdf_s = Argon2.Hash(good_pw, kdf_ref.public_hash, params=ovr_s) 167 | assert kdf_s.rehashed is True 168 | assert kdf_s.verified is True 169 | 170 | kdf_l = Argon2.Hash(good_pw, kdf_ref.public_hash, params=ovr_l) 171 | assert kdf_l.rehashed is True 172 | assert kdf_l.verified is True 173 | 174 | 175 | def test_argon2hash_duration(good_pw: str): 176 | def test(): 177 | Argon2.Hash(good_pw) 178 | 179 | assert timeit.timeit(test, number=1) > 0.35 180 | 181 | 182 | def test_argon2key_success(good_pw: str, test_context: Callable): 183 | with test_context(Argon2.Key): 184 | kdf1 = Argon2.Key(good_pw) 185 | 186 | assert isinstance(kdf1.public_salt, str) 187 | assert isinstance(kdf1.secret_key, bytes) 188 | 189 | kdf2 = Argon2.Key(good_pw, kdf1.public_salt) 190 | assert kdf2.secret_key == kdf1.secret_key 191 | 192 | kdf3 = Argon2.Key(good_pw, utils.b64(kdf1.public_salt)) 193 | assert kdf3.secret_key == kdf1.secret_key 194 | 195 | 196 | def test_argon2key_custom_hash_length(): 197 | kdf = Argon2.Key(b'anything', params=KDFParams( 198 | memory_cost=MemCost.MB(32), time_cost=1, parallelism=1, hash_len=30 199 | )) 200 | assert len(kdf.secret_key) == 30 201 | 202 | 203 | def test_argon2key_errors(good_pw: str, test_context: Callable): 204 | with test_context(Argon2.Key): 205 | with pytest.raises(errors.KDFWeakPasswordError): 206 | Argon2.Key('a' * 7) 207 | 208 | 209 | def test_argon2key_overrides(good_pw: str): 210 | ovr1 = KDFParams( 211 | memory_cost=MemCost.MB(32), 212 | parallelism=4, 213 | time_cost=1, 214 | hash_len=20, 215 | salt_len=20 216 | ) 217 | kdf = Argon2.Key(good_pw, params=ovr1) 218 | assert kdf.params == ovr1 219 | 220 | 221 | def test_argon2key_duration(good_pw: str): 222 | def test(): 223 | Argon2.Key(good_pw) 224 | 225 | assert timeit.timeit(test, number=1) > 3.5 226 | -------------------------------------------------------------------------------- /tests/test_pqa/test_dss.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | import pytest 13 | from pathlib import Path 14 | from typing import Callable, Type 15 | from pydantic import ValidationError 16 | from quantcrypt.internal import errors, constants as const 17 | from quantcrypt.internal.pqa import dss_algos as dss 18 | from quantcrypt.dss import DSSParamSizes, BaseDSS 19 | from .conftest import BaseAlgorithmTester 20 | 21 | 22 | class TestDssAlgorithms(BaseAlgorithmTester): 23 | @classmethod 24 | def test_mldsa_44(cls, alt_tmp_path): 25 | cls.run_tests(alt_tmp_path, dss.MLDSA_44) 26 | 27 | @classmethod 28 | def test_mldsa_65(cls, alt_tmp_path): 29 | cls.run_tests(alt_tmp_path, dss.MLDSA_65) 30 | 31 | @classmethod 32 | def test_mldsa_87(cls, alt_tmp_path): 33 | cls.run_tests(alt_tmp_path, dss.MLDSA_87) 34 | 35 | @classmethod 36 | def test_falcon_512(cls, alt_tmp_path): 37 | cls.run_tests(alt_tmp_path, dss.FALCON_512) 38 | 39 | @classmethod 40 | def test_falcon_1024(cls, alt_tmp_path): 41 | cls.run_tests(alt_tmp_path, dss.FALCON_1024) 42 | 43 | @classmethod 44 | def test_small_sphincs(cls, alt_tmp_path): 45 | cls.run_tests(alt_tmp_path, dss.SMALL_SPHINCS) 46 | 47 | @classmethod 48 | def test_fast_sphincs(cls, alt_tmp_path): 49 | cls.run_tests(alt_tmp_path, dss.FAST_SPHINCS) 50 | 51 | @classmethod 52 | def run_tests(cls, alt_tmp_path: Path, dss_class: Type[BaseDSS]): 53 | for dss_instance in cls.get_pqa_instances(dss_class): 54 | cls.run_attribute_tests(dss_instance) 55 | cls.run_cryptography_tests(dss_instance) 56 | cls.run_invalid_inputs_tests(dss_instance) 57 | cls.run_sign_verify_file_tests(dss_instance, alt_tmp_path) 58 | cls.run_armor_success_tests(dss_instance) 59 | cls.run_armor_failure_tests(dss_instance) 60 | cls.run_dearmor_failure_tests(dss_instance) 61 | 62 | @classmethod 63 | def run_attribute_tests(cls, dss_instance: BaseDSS): 64 | cls.notify(dss_instance, "Testing attributes") 65 | 66 | assert hasattr(dss_instance, "spec") 67 | assert isinstance(dss_instance.spec, const.AlgoSpec) 68 | 69 | assert hasattr(dss_instance, "variant") 70 | assert isinstance(dss_instance.variant, const.PQAVariant) 71 | 72 | assert hasattr(dss_instance, "param_sizes") 73 | assert isinstance(dss_instance.param_sizes, DSSParamSizes) 74 | 75 | assert hasattr(dss_instance, "keygen") 76 | assert isinstance(dss_instance.keygen, Callable) 77 | 78 | assert hasattr(dss_instance, "sign") 79 | assert isinstance(dss_instance.sign, Callable) 80 | 81 | assert hasattr(dss_instance, "verify") 82 | assert isinstance(dss_instance.verify, Callable) 83 | 84 | assert hasattr(dss_instance, "armor") 85 | assert isinstance(dss_instance.armor, Callable) 86 | 87 | assert hasattr(dss_instance, "dearmor") 88 | assert isinstance(dss_instance.dearmor, Callable) 89 | 90 | @classmethod 91 | def run_cryptography_tests(cls, dss_instance: BaseDSS): 92 | cls.notify(dss_instance, "Testing cryptography") 93 | 94 | message = b"Hello World" 95 | params = dss_instance.param_sizes 96 | public_key, secret_key = dss_instance.keygen() 97 | 98 | assert isinstance(public_key, bytes) 99 | assert len(public_key) == params.pk_size 100 | assert isinstance(secret_key, bytes) 101 | assert len(secret_key) == params.sk_size 102 | 103 | signature = dss_instance.sign(secret_key, message) 104 | assert isinstance(signature, bytes) 105 | assert len(signature) <= params.sig_size 106 | assert dss_instance.verify(public_key, message, signature, raises=False) 107 | 108 | @classmethod 109 | def run_invalid_inputs_tests(cls, dss_instance: BaseDSS): 110 | cls.notify(dss_instance, "Testing invalid inputs") 111 | 112 | message = b"Hello World" 113 | params = dss_instance.param_sizes 114 | public_key, secret_key = dss_instance.keygen() 115 | 116 | for isk in cls.invalid_keys(secret_key): 117 | with pytest.raises(ValidationError): 118 | dss_instance.sign(isk, message) 119 | 120 | for inv_msg in cls.invalid_messages(message): 121 | with pytest.raises(ValidationError): 122 | dss_instance.sign(secret_key, inv_msg) 123 | 124 | signature = dss_instance.sign(secret_key, message) 125 | 126 | for ipk in cls.invalid_keys(public_key): 127 | with pytest.raises(ValidationError): 128 | dss_instance.verify(ipk, message, signature) 129 | 130 | for inv_msg in cls.invalid_messages(message): 131 | with pytest.raises(ValidationError): 132 | dss_instance.verify(public_key, inv_msg, signature) 133 | 134 | for inv_sig in cls.invalid_signatures(signature, params.sig_size): 135 | with pytest.raises(ValidationError): 136 | dss_instance.verify(public_key, message, inv_sig) 137 | 138 | with pytest.raises(errors.DSSVerifyFailedError): 139 | dss_instance.verify(public_key[::-1], message, signature) 140 | 141 | with pytest.raises(errors.DSSVerifyFailedError): 142 | dss_instance.verify(public_key, message[::-1], signature) 143 | 144 | with pytest.raises(errors.DSSVerifyFailedError): 145 | dss_instance.verify(public_key, message, signature[::-1]) 146 | 147 | @classmethod 148 | def run_sign_verify_file_tests(cls, dss_instance: BaseDSS, alt_tmp_path: Path): 149 | cls.notify(dss_instance, "Testing file signature verification") 150 | 151 | data_file = alt_tmp_path / "test.txt" 152 | data_file.write_text("Lorem ipsum dolor sit amet, consectetur adipiscing elit.") 153 | public_key, secret_key = dss_instance.keygen() 154 | 155 | counter = [] 156 | def callback(): 157 | counter.append(1) 158 | 159 | ask = dss_instance.armor(secret_key) 160 | apk = dss_instance.armor(public_key) 161 | 162 | dss_instance.sign_file(ask, data_file, callback) 163 | assert sum(counter) == 1 164 | 165 | sf = dss_instance.sign_file(secret_key, data_file) 166 | assert sum(counter) == 1 167 | 168 | dss_instance.verify_file(apk, data_file, sf.signature, callback) 169 | assert sum(counter) == 2 170 | 171 | dss_instance.verify_file(public_key, data_file, sf.signature) 172 | assert sum(counter) == 2 173 | 174 | with pytest.raises(FileNotFoundError): 175 | dss_instance.sign_file(ask, Path("asdfg")) 176 | 177 | with pytest.raises(FileNotFoundError): 178 | dss_instance.verify_file(apk, Path("asdfg"), sf.signature) 179 | -------------------------------------------------------------------------------- /quantcrypt/internal/pqclean.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | from __future__ import annotations 13 | import re 14 | import yaml 15 | import requests 16 | import platform 17 | from typing import Literal 18 | from zipfile import ZipFile, ZipInfo 19 | from pathlib import Path 20 | from pydantic import BaseModel 21 | from functools import cache 22 | from itertools import product 23 | from quantcrypt.internal import constants as const 24 | from quantcrypt.internal import utils 25 | 26 | 27 | __all__ = [ 28 | "check_sources_exist", 29 | "find_pqclean_dir", 30 | "filter_archive_contents", 31 | "download_extract_pqclean", 32 | "get_common_filepaths", 33 | "PQASupportedPlatform", 34 | "PQAImplementation", 35 | "PQAMetaData", 36 | "read_algo_metadata", 37 | "check_opsys_support", 38 | "check_arch_support", 39 | "check_platform_support" 40 | ] 41 | 42 | 43 | def check_sources_exist(pqclean_dir: Path) -> bool: 44 | checked_files: list[bool] = [] 45 | specs = const.SupportedAlgos 46 | variants = const.PQAVariant.values() 47 | for spec, variant in product(specs, variants): 48 | path = pqclean_dir / spec.src_subdir / variant / "api.h" 49 | checked_files.append(path.exists()) 50 | return all(checked_files) 51 | 52 | 53 | def find_pqclean_dir(*, src_must_exist: bool) -> Path: 54 | res = utils.search_upwards("pqclean") 55 | if not src_must_exist or check_sources_exist(res): 56 | return res 57 | res = utils.search_upwards("pqclean", res.parent) 58 | if check_sources_exist(res): 59 | return res 60 | raise RuntimeError("Unable to find a valid pqclean directory") 61 | 62 | 63 | def filter_archive_contents(members: list[ZipInfo]) -> list[tuple[ZipInfo, Path]]: 64 | supported_algos = const.SupportedAlgos.pqclean_names() 65 | accepted_dirs = const.PQAType.values() 66 | filtered_members = [] 67 | 68 | for member in members: 69 | if member.is_dir(): 70 | continue 71 | match = re.search(r"/(.+)", member.filename) 72 | if not match: # pragma: no cover 73 | continue 74 | file_path = Path(match.group(1)) 75 | parts = file_path.parts 76 | if parts[0] not in accepted_dirs: 77 | continue 78 | elif parts[0] != "common" and parts[1] not in supported_algos: 79 | continue # NOSONAR 80 | filtered_members.append((member, file_path)) 81 | 82 | return filtered_members 83 | 84 | 85 | def download_extract_pqclean(pqclean_dir: Path) -> None: 86 | response = requests.get(const.PQCleanRepoArchiveURL, stream=True) 87 | response.raise_for_status() 88 | zip_path = pqclean_dir / "temp.zip" 89 | 90 | with open(zip_path, 'wb') as f: 91 | for chunk in response.iter_content(chunk_size=8192): 92 | if chunk: # pragma: no branch 93 | f.write(chunk) 94 | 95 | with ZipFile(zip_path, 'r') as zip_ref: 96 | for member, file_path in filter_archive_contents(zip_ref.infolist()): 97 | full_path = pqclean_dir / file_path 98 | full_path.parent.mkdir(parents=True, exist_ok=True) 99 | with full_path.open("wb") as f: 100 | f.write(zip_ref.read(member)) 101 | 102 | zip_path.unlink() 103 | 104 | 105 | def get_common_filepaths(variant: const.PQAVariant) -> tuple[str, list[str]]: 106 | path = find_pqclean_dir(src_must_exist=True) / "common" 107 | common, keccak2x, keccak4x = list(), list(), list() 108 | 109 | for file in path.rglob("**/*"): 110 | if file.is_file() and file.suffix in ['.c', '.S', '.s']: 111 | file = file.as_posix() 112 | files_list = common 113 | if 'keccak2x' in file: 114 | files_list = keccak2x 115 | elif 'keccak4x' in file: 116 | files_list = keccak4x 117 | files_list.append(file) 118 | 119 | if variant == const.PQAVariant.OPT_AMD: 120 | common.extend(keccak4x) 121 | elif variant == const.PQAVariant.OPT_ARM: 122 | common.extend(keccak2x) 123 | 124 | return path.as_posix(), common 125 | 126 | 127 | class PQASupportedPlatform(BaseModel): 128 | architecture: Literal["x86_64", "arm_8"] 129 | required_flags: list[str] | None = None 130 | operating_systems: list[str] | None = None 131 | 132 | 133 | class PQAImplementation(BaseModel): 134 | name: str 135 | supported_platforms: list[PQASupportedPlatform] | None = None 136 | 137 | 138 | class PQAMetaData(BaseModel): 139 | implementations: list[PQAImplementation] 140 | 141 | def filter(self, variant: const.PQAVariant) -> PQAImplementation | None: 142 | impl = [i for i in self.implementations if i.name == variant.value] 143 | return impl[0] if impl else None 144 | 145 | 146 | @cache 147 | def read_algo_metadata(spec: const.AlgoSpec) -> PQAMetaData: 148 | pqclean_dir = find_pqclean_dir(src_must_exist=True) 149 | meta_file = pqclean_dir / spec.src_subdir / "META.yml" 150 | with meta_file.open('r') as file: 151 | data: dict = yaml.full_load(file) 152 | return PQAMetaData(**data) 153 | 154 | 155 | def check_opsys_support(spf: PQASupportedPlatform) -> str | None: 156 | for opsys in spf.operating_systems: 157 | if platform.system().lower() == opsys.lower(): 158 | return opsys.lower() 159 | return None 160 | 161 | 162 | def check_arch_support(impl: PQAImplementation) -> PQASupportedPlatform | None: 163 | supported_arches = const.AMDArches 164 | if impl.name == const.PQAVariant.OPT_ARM.value: 165 | supported_arches = const.ARMArches 166 | for spf in impl.supported_platforms: 167 | if platform.machine().lower() in supported_arches: 168 | return spf 169 | return None 170 | 171 | 172 | def check_platform_support( 173 | spec: const.AlgoSpec, 174 | variant: const.PQAVariant 175 | ) -> tuple[Path, list[str]] | tuple[None, None]: 176 | required_flags: list[str] = [] 177 | meta = read_algo_metadata(spec) 178 | impl = meta.filter(variant) 179 | 180 | if not impl: # pragma: no cover 181 | return None, None 182 | elif impl.supported_platforms: 183 | spf = check_arch_support(impl) 184 | if not spf: 185 | return None, None 186 | if spf.operating_systems: 187 | opsys = check_opsys_support(spf) 188 | if not opsys: 189 | return None, None 190 | for x, y in const.ExcludedCombinations: 191 | if x == opsys and y == variant: # pragma: no cover 192 | return None, None 193 | if spf.required_flags: # pragma: no branch 194 | required_flags = spf.required_flags 195 | 196 | pqclean_dir = find_pqclean_dir(src_must_exist=True) 197 | path = pqclean_dir / spec.src_subdir / variant.value 198 | return path, required_flags 199 | -------------------------------------------------------------------------------- /quantcrypt/internal/cli/annotations.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | from typer import Option, Argument 13 | from typing import Annotated 14 | from quantcrypt.internal import constants as const 15 | 16 | 17 | __all__ = [ 18 | "KeygenAlgo", 19 | "CompileAlgos", 20 | "RemoveAlgos", 21 | "KeepAlgos", 22 | "OnlyRef", 23 | "WithOpt", 24 | "Version", 25 | "DryRun", 26 | "Overwrite", 27 | "NonInteractive", 28 | "PubKeyFile", 29 | "SecKeyFile", 30 | "EncInFile", 31 | "EncOutFile", 32 | "DecInFile", 33 | "DecOutFile", 34 | "SignDataFile", 35 | "VerifyDataFile", 36 | "WriteSigFile", 37 | "ReadSigFile", 38 | "Identifier", 39 | "Directory" 40 | ] 41 | 42 | 43 | _algo_choices = ' | '.join(const.SupportedAlgos.armor_names()) 44 | _rel_path_msg = "If the path is relative, it is evaluated from the Current Working Directory." 45 | 46 | 47 | KeygenAlgo = Annotated[str, Argument( 48 | show_default=False, case_sensitive=False, help=' '.join([ 49 | "Name of the algorithm with which to generate the keypair (case insensitive).", 50 | f"Available choices: {_algo_choices}" 51 | ]) 52 | )] 53 | 54 | CompileAlgos = Annotated[list[str], Argument( 55 | show_default=False, case_sensitive=False, help=' '.join([ 56 | "Names of the algorithms which to compile, optional (case insensitive)." , 57 | "If not provided, clean reference variants of ALL available algorithms", 58 | "will be compiled. Can accept multiple values separated by spaces.", 59 | f"Available choices: {_algo_choices}" 60 | ]) 61 | )] 62 | 63 | RemoveAlgos = Annotated[list[str], Argument( 64 | show_default=False, case_sensitive=False, help=' '.join([ 65 | "Names of the PQC algorithms which to remove from the library (case insensitive).", 66 | "Can accept multiple values separated by spaces.", 67 | f"Available choices: {_algo_choices}" 68 | ]) 69 | )] 70 | 71 | KeepAlgos = Annotated[bool, Option( 72 | "--keep", "-k", show_default=False, help=' '.join([ 73 | "Inverts the meaning of the algorithm names which to remove from the library,", 74 | "keeping the named algorithms and removing everything else instead." 75 | ]) 76 | )] 77 | 78 | OnlyRef = Annotated[bool, Option( 79 | "--only-ref", "-r", show_default=False, help=' '.join([ 80 | "Can be used together with the --keep option to keep only the clean reference binaries", 81 | "of algorithms. Useful for when QuantCrypt is being used within virtualized environments", 82 | "which do not support specialized CPU instructions." 83 | ]) 84 | )] 85 | 86 | WithOpt = Annotated[bool, Option( 87 | "--with-opt", "-o", show_default=False, help=' '.join([ 88 | "Includes architecture-specific optimized variants to compilation targets.", 89 | "On x86_64 systems, this will add avx2 variants and on ARM systems, this will add aarch64 variants." 90 | ]) 91 | )] 92 | 93 | Version = Annotated[bool, Option( 94 | '--version', '-v', show_default=False, 95 | help="Prints version number to the console and exits." 96 | )] 97 | 98 | DryRun = Annotated[bool, Option( 99 | "--dry-run", "-D", show_default=False, 100 | help="Skips actual file operations. Useful for testing purposes." 101 | )] 102 | 103 | Overwrite = Annotated[bool, Option( 104 | "--overwrite", "-W", show_default=False, 105 | help="Disables interactive confirmation prompt for overwriting files." 106 | )] 107 | 108 | NonInteractive = Annotated[bool, Option( 109 | "--no-ask", "-N", show_default=False, help=' '.join([ 110 | "Disables interactive prompts. If the program is going to overwrite", 111 | "files and the --overwrite option is not separately provided, the", 112 | "program will exit with an exit code of 1." 113 | ]) 114 | )] 115 | 116 | PubKeyFile = Annotated[str, Option( 117 | '--pk-file', '-p', show_default=False, help=' '.join([ 118 | "Either an absolute or a relative path to an armored PQA public key file.", 119 | _rel_path_msg 120 | ]) 121 | )] 122 | 123 | SecKeyFile = Annotated[str, Option( 124 | '--sk-file', '-s', show_default=False, help=' '.join([ 125 | "Either an absolute or a relative path to an armored PQA secret key file.", 126 | _rel_path_msg 127 | ]) 128 | )] 129 | 130 | EncInFile = Annotated[str, Option( 131 | '--in-file', '-i', show_default=False, help=' '.join([ 132 | "Path to the plaintext data file, which will be encrypted with the Krypton cipher.", 133 | _rel_path_msg 134 | ]) 135 | )] 136 | 137 | EncOutFile = Annotated[str, Option( 138 | '--out-file', '-o', show_default=False, help=' '.join([ 139 | "Path to the output file where the encrypted data will be written to, optional.", 140 | "Defaults to the Current Working Directory, using the data file name " 141 | f"with the {const.KryptonFileSuffix} suffix." 142 | ]) 143 | )] 144 | 145 | DecInFile = Annotated[str, Option( 146 | '--in-file', '-i', show_default=False, help=' '.join([ 147 | "Path to the ciphertext data file, which will be decrypted with the Krypton cipher.", 148 | _rel_path_msg 149 | ]) 150 | )] 151 | 152 | DecOutFile = Annotated[str, Option( 153 | '--out-file', '-o', show_default=False, help=' '.join([ 154 | "Path to the output file where the decrypted data will be written to, optional.", 155 | "Defaults to the Current Working Directory, using the original filename of the", 156 | "plaintext file that was stored into the ciphertext file." 157 | ]) 158 | )] 159 | 160 | SignDataFile = Annotated[str, Option( 161 | '--in-file', '-i', show_default=False, help=' '.join([ 162 | "Path to the data file, which will be signed by a DSS algorithm.", 163 | "The appropriate DSS algorithm is deduced from the contents of the armored key file.", 164 | _rel_path_msg 165 | ]) 166 | )] 167 | 168 | VerifyDataFile = Annotated[str, Option( 169 | '--in-file', '-i', show_default=False, help=' '.join([ 170 | "Path to the data file, which will be verified by a DSS algorithm.", 171 | "The appropriate DSS algorithm is deduced from the contents of the armored key file.", 172 | _rel_path_msg 173 | ]) 174 | )] 175 | 176 | WriteSigFile = Annotated[str, Option( 177 | '--sig-file', '-S', show_default=False, help=' '.join([ 178 | "Path to a file where the signature data will be written to, optional.", 179 | "Defaults to the Current Working Directory, using the data file name " 180 | f"with the {const.SignatureFileSuffix} suffix." 181 | ]) 182 | )] 183 | 184 | ReadSigFile = Annotated[str, Option( 185 | '--sig-file', '-S', show_default=False, help=' '.join([ 186 | "Path to a file where the signature data will be read from, optional.", 187 | "Defaults to the Current Working Directory, using the data file name " 188 | f"with the {const.SignatureFileSuffix} suffix." 189 | ]) 190 | )] 191 | 192 | Identifier = Annotated[str, Option( 193 | "--id", "-i", show_default=False, 194 | help="Unique identifier to prepend to the names of the keyfiles, optional." 195 | )] 196 | 197 | Directory = Annotated[str, Option( 198 | "--dir", "-d", show_default=False, help=' '.join([ 199 | "Directory where to save the generated keypair, optional.", 200 | "If the directory doesn't exist, it will be created with parents.", 201 | "If not provided, the keys are saved into the Current Working Directory." 202 | ]) 203 | )] 204 | -------------------------------------------------------------------------------- /tests/test_kdf/test_kkdf.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | import pytest 13 | from quantcrypt.kdf import KKDF 14 | from quantcrypt.internal import errors 15 | 16 | 17 | def test_kkdf_instantiation_with_minimum_args(): 18 | master_key = b'\x00' * 32 19 | result = KKDF(master=master_key) 20 | assert isinstance(result, tuple), \ 21 | "Result should be a tuple" 22 | assert isinstance(result[0], bytes), \ 23 | "All elements in result should be bytes" 24 | assert len(result) == 1, \ 25 | "At least one key should be generated" 26 | 27 | 28 | def test_kkdf_instantiation_with_all_params(): 29 | result = KKDF( 30 | master=b'\x00' * 32, 31 | key_len=64, 32 | num_keys=2, 33 | salt=b'\x01' * 64, 34 | context=b'\x02' * 64 35 | ) 36 | assert isinstance(result, tuple), \ 37 | "Result should be a tuple" 38 | assert len(result) == 2, \ 39 | "Number of keys in result should match num_keys" 40 | assert all(len(key) == 64 for key in result), \ 41 | "Each key should have length equal to key_len" 42 | assert all(isinstance(key, bytes) for key in result), \ 43 | "All elements in result should be bytes" 44 | assert len({key for key in result}) == len(result), \ 45 | "All keys should be unique" 46 | 47 | 48 | def test_kkdf_short_master_key(): 49 | with pytest.raises(ValueError): 50 | KKDF( 51 | master=b'\x00' * 31, # Master key is only 31 bytes long 52 | key_len=32, 53 | num_keys=1, 54 | salt=None, 55 | context=None 56 | ) 57 | 58 | 59 | def test_kkdf_invalid_key_len(): 60 | # Test with key_len less than 32 61 | with pytest.raises(ValueError): 62 | KKDF( 63 | master=b'\x00' * 32, 64 | key_len=31, # Invalid key_len 65 | num_keys=1, 66 | salt=None, 67 | context=None 68 | ) 69 | # Test with key_len greater than 1024 70 | with pytest.raises(ValueError): 71 | KKDF( 72 | master=b'\x00' * 32, 73 | key_len=1025, # Invalid key_len 74 | num_keys=1, 75 | salt=None, 76 | context=None 77 | ) 78 | 79 | 80 | def test_kkdf_invalid_num_keys(): 81 | # Test with num_keys less than 1 82 | with pytest.raises(ValueError): 83 | KKDF( 84 | master=b'\x00' * 32, 85 | key_len=32, 86 | num_keys=0, # Invalid num_keys 87 | salt=None, 88 | context=None 89 | ) 90 | # Test with num_keys greater than 2048 91 | with pytest.raises(ValueError): 92 | KKDF( 93 | master=b'\x00' * 32, 94 | key_len=32, 95 | num_keys=2049, # Invalid num_keys 96 | salt=None, 97 | context=None 98 | ) 99 | 100 | 101 | def test_kkdf_custom_salt_and_context(): 102 | # Test with specific salt and context 103 | result_with_custom_salt_and_context = KKDF( 104 | master=b'\x00' * 32, 105 | key_len=32, 106 | num_keys=1, 107 | salt=b'\x01' * 64, # Custom salt 108 | context=b'\x02' * 64 # Custom context 109 | ) 110 | # Test with default salt and context (None) 111 | result_with_default_salt_and_context = KKDF( 112 | master=b'\x00' * 32, 113 | key_len=32, 114 | num_keys=1, 115 | salt=None, 116 | context=None 117 | ) 118 | assert result_with_custom_salt_and_context != result_with_default_salt_and_context, \ 119 | "Outputs should differ when using custom salt and context versus defaults" 120 | 121 | 122 | def test_kkdf_max_allowed_entropy(): 123 | KKDF( 124 | master=b'\x00' * 32, 125 | key_len=64, 126 | num_keys=1024 127 | ) 128 | 129 | 130 | def test_kkdf_key_len_entropy_limit_error(): 131 | with pytest.raises(errors.KDFOutputLimitError): 132 | KKDF( 133 | master=b'\x00' * 32, 134 | key_len=65, # Key length set to exceed the entropy limit when multiplied by num_keys 135 | num_keys=1024 136 | ) 137 | 138 | 139 | def test_kkdf_num_keys_entropy_limit_error(): 140 | with pytest.raises(errors.KDFOutputLimitError): 141 | KKDF( 142 | master=b'\x00' * 32, 143 | key_len=64, 144 | num_keys=1025 # Number of keys set to exceed the entropy limit when multiplied by key_len 145 | ) 146 | 147 | 148 | def test_kkdf_unique_keys_different_master(): 149 | base = b'\x01' * 31 150 | result_1 = KKDF( 151 | master=base + b'\x02', 152 | key_len=32, 153 | num_keys=1 154 | ) 155 | result_2 = KKDF( 156 | master=base + b'\x03', 157 | key_len=32, 158 | num_keys=1 159 | ) 160 | assert result_1 != result_2, \ 161 | "Generated keys should be different for different master keys" 162 | 163 | 164 | def test_kkdf_different_salt_produces_different_keys(): 165 | base = b'\x01' * 63 166 | result_with_salt_context_1 = KKDF( 167 | master=b'\x00' * 32, 168 | key_len=32, 169 | num_keys=1, 170 | salt=base + b'\x02', 171 | context=b'\x01' * 64 172 | ) 173 | result_with_salt_context_2 = KKDF( 174 | master=b'\x00' * 32, 175 | key_len=32, 176 | num_keys=1, 177 | salt=base + b'\x03', 178 | context=b'\x01' * 64 179 | ) 180 | assert result_with_salt_context_1 != result_with_salt_context_2, \ 181 | "Changing salt should produce different keys" 182 | 183 | 184 | def test_kkdf_different_context_produces_different_keys(): 185 | base = b'\x01' * 63 186 | result_with_salt_context_1 = KKDF( 187 | master=b'\x00' * 32, 188 | key_len=32, 189 | num_keys=1, 190 | salt=b'\x01' * 64, 191 | context=base + b'\x02' 192 | ) 193 | result_with_salt_context_2 = KKDF( 194 | master=b'\x00' * 32, 195 | key_len=32, 196 | num_keys=1, 197 | salt=b'\x01' * 64, 198 | context=base + b'\x03' 199 | ) 200 | assert result_with_salt_context_1 != result_with_salt_context_2, \ 201 | "Changing context should produce different keys" 202 | 203 | 204 | def test_kkdf_output_structure_and_length(): 205 | num_keys_test = 5 206 | result = KKDF( 207 | master=b'\x00' * 32, 208 | key_len=32, 209 | num_keys=num_keys_test 210 | ) 211 | assert isinstance(result, tuple), \ 212 | "Output should be a tuple" 213 | assert len(result) == num_keys_test, \ 214 | "Length of output should match num_keys" 215 | assert all(isinstance(key, bytes) for key in result), \ 216 | "Each element in the output should be of type bytes" 217 | 218 | 219 | def test_kkdf_key_length_in_output(): 220 | key_length_test = 64 221 | result = KKDF( 222 | master=b'\x00' * 32, 223 | key_len=key_length_test, 224 | num_keys=3 225 | ) 226 | assert all(len(key) == key_length_test for key in result), \ 227 | "Each key in the output should have a length equal to key_len" 228 | 229 | 230 | def test_kkdf_handling_none_salt_context(): 231 | result_with_none_salt_context = KKDF( 232 | master=b'\x00' * 32, 233 | key_len=32, 234 | num_keys=1, 235 | salt=None, 236 | context=None 237 | ) 238 | result_with_default_salt_context = KKDF( 239 | master=b'\x00' * 32, 240 | key_len=32, 241 | num_keys=1 242 | ) 243 | assert result_with_none_salt_context == result_with_default_salt_context, \ 244 | "Outputs should be identical when salt and context are None or defaults" 245 | 246 | 247 | def test_kkdf_smallest_valid_key_len(): 248 | result = KKDF( 249 | master=b'\x00' * 32, 250 | key_len=32, 251 | num_keys=1 252 | ) 253 | assert len(result[0]) == 32, \ 254 | "The generated key should have the smallest valid length of 32 bytes" 255 | 256 | 257 | def test_kkdf_valid_master_key_length(): 258 | result = KKDF( 259 | master=b'\x00' * 32, 260 | key_len=32, 261 | num_keys=1 262 | ) 263 | assert isinstance(result, tuple) and len(result) > 0, \ 264 | "KKDF should accept a 32-byte master key and generate at least one key" 265 | 266 | 267 | def test_kkdf_iter_byte_incrementation(): 268 | result_one_key = KKDF( 269 | master=b'\x00' * 32, 270 | key_len=64, 271 | num_keys=1 272 | ) 273 | result_two_keys = KKDF( 274 | master=b'\x00' * 32, 275 | key_len=64, 276 | num_keys=2 277 | ) 278 | assert result_one_key[0] != result_two_keys[1], \ 279 | "Second key in two-key output should differ from single key output" 280 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # QuantCrypt 2 | 3 | Logo 4 | 5 | 6 | [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/quantcrypt)](https://pypi.org/project/quantcrypt/) 7 | [![GitHub License](https://img.shields.io/github/license/aabmets/quantcrypt)](https://github.com/aabmets/quantcrypt/blob/main/LICENSE) 8 | [![codecov](https://codecov.io/gh/aabmets/quantcrypt/graph/badge.svg?token=jymcRynp2P)](https://codecov.io/gh/aabmets/quantcrypt) 9 | [![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/aabmets/quantcrypt/pytest-codecov.yml?label=tests)](https://github.com/aabmets/quantcrypt/actions/workflows/pytest-codecov.yml) 10 | [![PyPI - Downloads](https://img.shields.io/pypi/dm/quantcrypt)](https://pypistats.org/packages/quantcrypt) 11 | 12 | 13 | [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=aabmets_quantcrypt&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=aabmets_quantcrypt) 14 | [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=aabmets_quantcrypt&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=aabmets_quantcrypt) 15 | [![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=aabmets_quantcrypt&metric=reliability_rating)](https://sonarcloud.io/summary/new_code?id=aabmets_quantcrypt) 16 | [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=aabmets_quantcrypt&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=aabmets_quantcrypt)
17 | [![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=aabmets_quantcrypt&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=aabmets_quantcrypt) 18 | [![Bugs](https://sonarcloud.io/api/project_badges/measure?project=aabmets_quantcrypt&metric=bugs)](https://sonarcloud.io/summary/new_code?id=aabmets_quantcrypt) 19 | [![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=aabmets_quantcrypt&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=aabmets_quantcrypt) 20 | [![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=aabmets_quantcrypt&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=aabmets_quantcrypt) 21 | 22 | 23 | ## Description 24 | 25 | QuantCrypt is a cross-platform Python library for Post-Quantum Cryptography using precompiled PQClean binaries. 26 | While QuantCrypt contains multiple variants of PQC algorithms that are standardized by [NIST](https://csrc.nist.gov/projects/post-quantum-cryptography), 27 | it is recommended to use only the strongest variants as recommended by the [CNSA advisory by NSA](https://en.wikipedia.org/wiki/Commercial_National_Security_Algorithm_Suite). 28 | 29 | 30 | ## Motivation 31 | 32 | Currently, there does not exist any pure-Python implementation of Post-Quantum Cryptographic algorithms, 33 | which requires Python developers to first discover where to get reliable C source code of PQC algorithms, 34 | then install the necessary C compilers on their system and then figure out how to use CFFI to compile and 35 | use the C code in their Python source code. Furthermore, those binaries would be only compatible with the 36 | platform that they were compiled on, making it very difficult to use separate platforms for development 37 | and deployment workflows, without having to recompile the C source code each time. 38 | 39 | This library solves this problem by pre-compiling the C source code of PQC algorithms for Windows, Linux and 40 | Darwin platforms in GitHub Actions using CFFI, and it also provides a nice Python wrapper around the PQC binaries. 41 | Since I wanted this library to be all-encompassing, it also contains a lot of helper classes which one might need 42 | when working with Post-Quantum cryptography. This library places a lot of focus on Developer Experience, aiming 43 | to be powerful in features, yet easy and enjoyable to use, so it would _just work_ for your project. 44 | 45 | 46 | ## Quickstart 47 | 48 | The full documentation of this library can be found in the [Wiki](https://github.com/aabmets/quantcrypt/wiki). 49 | Because this library is rich in docstrings which provide detailed insight into the library's behavior, 50 | it is suggested to use an IDE which supports autocomplete and code insights when working with this library. 51 | Most popular choices are either PyCharm or VS Code with Python-specific plugins. 52 | 53 | 54 | ### Install 55 | 56 | To install QuantCrypt with its default dependencies (no compiler), use one of the following commands: 57 | 58 | Using [UV](https://docs.astral.sh/uv/) _(recommended)_: 59 | ```shell 60 | uv add quantcrypt 61 | ``` 62 | 63 | Using [Poetry](https://python-poetry.org/docs/): 64 | ```shell 65 | poetry add quantcrypt 66 | ``` 67 | 68 | Using [pip](https://pip.pypa.io/en/stable/getting-started/): 69 | ```shell 70 | pip install quantcrypt 71 | ``` 72 | 73 | 74 | If you want to recompile PQA binaries on your own machine, you can install QuantCrypt with 75 | optional dependencies by appending `[compiler]` to one of the install commands outlined above. 76 | 77 | QuantCrypt publishes prebuilt wheels with precompiled binaries to the PyPI registry. 78 | If your platform supports one of the prebuilt wheels, then you don't need to install 79 | QuantCrypt with the compiler option to be able to use the library. 80 | 81 | _**Note:**_ If you do decide to recompile PQA binaries, you will need to install platform-specific `C/C++` build 82 | tools like [Visual Studio](https://visualstudio.microsoft.com/), [Xcode](https://developer.apple.com/xcode/) or 83 | [GNU Make](https://www.gnu.org/software/make/) _(non-exhaustive list)_. 84 | 85 | _**Note:**_ If you attempt to import the compiler module programmatically when optional dependencies 86 | are missing, you will receive an import error. 87 | 88 | 89 | ### Script Imports 90 | 91 | ```python 92 | from quantcrypt import ( 93 | kem, # Key Encapsulation Mechanism algos - public-key cryptography 94 | dss, # Digital Signature Scheme algos - secret-key signatures 95 | cipher, # The Krypton Cipher - symmetric cipher based on AES-256 96 | kdf, # Argon2 helpers + KMAC-KDF - key derivation functions 97 | errors, # All errors QuantCrypt may raise - also available from other modules 98 | utils, # Helper utilities from all modules - gathered into one module 99 | compiler # Tools for compiling PQA binaries - requires optional dependencies 100 | ) 101 | ``` 102 | 103 | ### CLI Commands 104 | 105 | The general functionality of this library is also available from the command-line, which you can access 106 | with the `qclib` command. Keep in mind that if you install QuantCrypt into a venv, you will need to activate 107 | the venv to access the CLI. QuantCrypt uses [Typer](https://typer.tiangolo.com/) internally to provide the CLI experience. 108 | You can use the `--help` option to learn more about each command and subcommand. 109 | 110 | ```shell 111 | qclib --help 112 | qclib --version 113 | 114 | qclib info --help 115 | qclib keygen --help 116 | qclib encrypt --help 117 | qclib decrypt --help 118 | qclib sign --help 119 | qclib verify --help 120 | qclib remove --help 121 | qclib compile --help 122 | ``` 123 | 124 | _**Note:**_ The `compile` CLI command becomes available when QuantCrypt 125 | has been installed with optional dependencies for the compiler. 126 | 127 | 128 | ## Security Statement 129 | 130 | The PQC algorithms used in this library inherit their security from the [PQClean](https://github.com/PQClean/PQClean) project. 131 | You can read the security statement of the PQClean project from their [SECURITY.md](https://github.com/PQClean/PQClean/blob/master/SECURITY.md) file. 132 | To report a security vulnerability for a PQC algorithm, please create an [issue](https://github.com/PQClean/PQClean/issues) in the PQClean repository. 133 | 134 | 135 | ## Credits 136 | 137 | This library would be impossible without these essential dependencies: 138 | 139 | * [PQClean](https://github.com/PQClean/PQClean) - C source code of Post-Quantum Cryptography algorithms 140 | * [Cryptodome](https://pypi.org/project/pycryptodome/) - AES-256 and SHA3 implementation 141 | * [Argon2-CFFI](https://pypi.org/project/argon2-cffi/) - Argon2 KDF implementation 142 | 143 | I thank the creators and maintainers of these libraries for their hard work. 144 | -------------------------------------------------------------------------------- /quantcrypt/internal/pqa/dss_algos.py: -------------------------------------------------------------------------------- 1 | # 2 | # MIT License 3 | # 4 | # Copyright (c) 2024, Mattias Aabmets 5 | # 6 | # The contents of this file are subject to the terms and conditions defined in the License. 7 | # You may not use, modify, or distribute this file except in compliance with the License. 8 | # 9 | # SPDX-License-Identifier: MIT 10 | # 11 | 12 | from quantcrypt.internal import utils, constants as const 13 | from quantcrypt.internal.pqa.base_dss import BaseDSS 14 | 15 | 16 | __all__ = [ 17 | "MLDSA_44", 18 | "MLDSA_65", 19 | "MLDSA_87", 20 | "FALCON_512", 21 | "FALCON_1024", 22 | "FAST_SPHINCS", 23 | "SMALL_SPHINCS" 24 | ] 25 | 26 | 27 | class MLDSA_44(BaseDSS): # NOSONAR 28 | @utils.input_validator() 29 | def __init__(self, variant: const.PQAVariant = None, *, allow_fallback: bool = True) -> None: 30 | """ 31 | Initializes the MLDSA_44 digital signature scheme algorithm 32 | instance with compiled C extension binaries. 33 | 34 | :param variant: Which compiled binary to use underneath. 35 | When variant is None *(auto-select mode)*, QuantCrypt will first try to use 36 | platform-optimized binaries. If it fails to do so and fallback is allowed, 37 | it will then try to fall back to using clean reference binaries. 38 | :param allow_fallback: Allow falling back to using clean reference binaries when 39 | QuantCrypt has failed to import platform-optimized binaries. Defaults to True. 40 | :raises - ImportFailedError: When QuantCrypt has failed to fall back to using clean 41 | reference binaries, either because they are missing or fallback was not permitted. 42 | """ 43 | super().__init__(variant, allow_fallback) 44 | 45 | 46 | class MLDSA_65(BaseDSS): # NOSONAR 47 | @utils.input_validator() 48 | def __init__(self, variant: const.PQAVariant = None, *, allow_fallback: bool = True) -> None: 49 | """ 50 | Initializes the MLDSA_65 digital signature scheme algorithm 51 | instance with compiled C extension binaries. 52 | 53 | :param variant: Which compiled binary to use underneath. 54 | When variant is None *(auto-select mode)*, QuantCrypt will first try to use 55 | platform-optimized binaries. If it fails to do so and fallback is allowed, 56 | it will then try to fall back to using clean reference binaries. 57 | :param allow_fallback: Allow falling back to using clean reference binaries when 58 | QuantCrypt has failed to import platform-optimized binaries. Defaults to True. 59 | :raises - ImportFailedError: When QuantCrypt has failed to fall back to using clean 60 | reference binaries, either because they are missing or fallback was not permitted. 61 | """ 62 | super().__init__(variant, allow_fallback) 63 | 64 | 65 | class MLDSA_87(BaseDSS): # NOSONAR 66 | @utils.input_validator() 67 | def __init__(self, variant: const.PQAVariant = None, *, allow_fallback: bool = True) -> None: 68 | """ 69 | Initializes the MLDSA_87 digital signature scheme algorithm 70 | instance with compiled C extension binaries. 71 | 72 | :param variant: Which compiled binary to use underneath. 73 | When variant is None *(auto-select mode)*, QuantCrypt will first try to use 74 | platform-optimized binaries. If it fails to do so and fallback is allowed, 75 | it will then try to fall back to using clean reference binaries. 76 | :param allow_fallback: Allow falling back to using clean reference binaries when 77 | QuantCrypt has failed to import platform-optimized binaries. Defaults to True. 78 | :raises - ImportFailedError: When QuantCrypt has failed to fall back to using clean 79 | reference binaries, either because they are missing or fallback was not permitted. 80 | """ 81 | super().__init__(variant, allow_fallback) 82 | 83 | 84 | class FALCON_512(BaseDSS): # NOSONAR 85 | @utils.input_validator() 86 | def __init__(self, variant: const.PQAVariant = None, *, allow_fallback: bool = True) -> None: 87 | """ 88 | Initializes the FALCON_512 digital signature scheme algorithm 89 | instance with compiled C extension binaries. 90 | 91 | :param variant: Which compiled binary to use underneath. 92 | When variant is None *(auto-select mode)*, QuantCrypt will first try to use 93 | platform-optimized binaries. If it fails to do so and fallback is allowed, 94 | it will then try to fall back to using clean reference binaries. 95 | :param allow_fallback: Allow falling back to using clean reference binaries when 96 | QuantCrypt has failed to import platform-optimized binaries. Defaults to True. 97 | :raises - ImportFailedError: When QuantCrypt has failed to fall back to using clean 98 | reference binaries, either because they are missing or fallback was not permitted. 99 | """ 100 | super().__init__(variant, allow_fallback) 101 | 102 | 103 | class FALCON_1024(BaseDSS): # NOSONAR 104 | @utils.input_validator() 105 | def __init__(self, variant: const.PQAVariant = None, *, allow_fallback: bool = True) -> None: 106 | """ 107 | Initializes the FALCON_1024 digital signature scheme algorithm 108 | instance with compiled C extension binaries. 109 | 110 | :param variant: Which compiled binary to use underneath. 111 | When variant is None *(auto-select mode)*, QuantCrypt will first try to use 112 | platform-optimized binaries. If it fails to do so and fallback is allowed, 113 | it will then try to fall back to using clean reference binaries. 114 | :param allow_fallback: Allow falling back to using clean reference binaries when 115 | QuantCrypt has failed to import platform-optimized binaries. Defaults to True. 116 | :raises - ImportFailedError: When QuantCrypt has failed to fall back to using clean 117 | reference binaries, either because they are missing or fallback was not permitted. 118 | """ 119 | super().__init__(variant, allow_fallback) 120 | 121 | 122 | class FAST_SPHINCS(BaseDSS): # NOSONAR 123 | @utils.input_validator() 124 | def __init__(self, variant: const.PQAVariant = None, *, allow_fallback: bool = True) -> None: 125 | """ 126 | Initializes the FAST_SPHINCS digital signature scheme algorithm 127 | instance with compiled C extension binaries. 128 | 129 | :param variant: Which compiled binary to use underneath. 130 | When variant is None *(auto-select mode)*, QuantCrypt will first try to use 131 | platform-optimized binaries. If it fails to do so and fallback is allowed, 132 | it will then try to fall back to using clean reference binaries. 133 | :param allow_fallback: Allow falling back to using clean reference binaries when 134 | QuantCrypt has failed to import platform-optimized binaries. Defaults to True. 135 | :raises - ImportFailedError: When QuantCrypt has failed to fall back to using clean 136 | reference binaries, either because they are missing or fallback was not permitted. 137 | """ 138 | super().__init__(variant, allow_fallback) 139 | 140 | 141 | class SMALL_SPHINCS(BaseDSS): # NOSONAR 142 | @utils.input_validator() 143 | def __init__(self, variant: const.PQAVariant = None, *, allow_fallback: bool = True) -> None: 144 | """ 145 | Initializes the SMALL_SPHINCS digital signature scheme algorithm 146 | instance with compiled C extension binaries. 147 | 148 | :param variant: Which compiled binary to use underneath. 149 | When variant is None *(auto-select mode)*, QuantCrypt will first try to use 150 | platform-optimized binaries. If it fails to do so and fallback is allowed, 151 | it will then try to fall back to using clean reference binaries. 152 | :param allow_fallback: Allow falling back to using clean reference binaries when 153 | QuantCrypt has failed to import platform-optimized binaries. Defaults to True. 154 | :raises - ImportFailedError: When QuantCrypt has failed to fall back to using clean 155 | reference binaries, either because they are missing or fallback was not permitted. 156 | """ 157 | super().__init__(variant, allow_fallback) 158 | --------------------------------------------------------------------------------