diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index e7f5d42..6d445d3 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -70,41 +70,6 @@ jobs: - name: pytest run: python -m pytest python/tests - # PRs are rebase-merged, so every commit lands on dev-v2 as is. Re-run the - # checks on each commit of the PR, not only the tip. - commits: - name: every commit passes - if: ${{ github.event_name == 'pull_request' }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - ref: ${{ github.event.pull_request.head.sha }} - - uses: actions/setup-python@v6 - with: - python-version: '3.x' - - name: Install Rust toolchain - run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy --no-self-update - - name: Create virtualenv - run: | - python -m venv "$RUNNER_TEMP/venv" - echo "VIRTUAL_ENV=$RUNNER_TEMP/venv" >> "$GITHUB_ENV" - echo "$RUNNER_TEMP/venv/bin" >> "$GITHUB_PATH" - - name: Install dev dependencies - run: python -m pip install -r requirements-dev.txt - - name: Run checks on every commit - env: - BASE_REF: ${{ github.base_ref }} - run: | - git config user.name "ci" - git config user.email "ci@users.noreply.github.com" - git fetch origin "$BASE_REF" - # Copy the script out of the tree: commits older than the one that - # adds scripts/check.sh must still be checked with it. - cp scripts/check.sh "$RUNNER_TEMP/check.sh" - git rebase --exec "bash $RUNNER_TEMP/check.sh" "origin/$BASE_REF" - linux: runs-on: ${{ matrix.platform.runner }} strategy: diff --git a/AGENTS.md b/AGENTS.md index ea08edd..1649c70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,8 @@ # AGENTS.md — instructions for AI coding agents -You are working on **pykyber**, a post-quantum KEM library: an in-house Rust implementation (no third-party crypto at runtime) with PyO3 bindings, released under the Unlicense. People will run your code to protect real secrets. A test that passes is necessary but not sufficient; code that is correct but leaks timing is a vulnerability. +You are working on **pykyber**, a post-quantum KEM library: an in-house Rust implementation (no third-party crypto at runtime) with PyO3 bindings, released under MIT OR Apache-2.0 (version 1.0.5 was released under the Unlicense). People will run your code to protect real secrets. A test that passes is necessary but not sufficient; code that is correct but leaks timing is a vulnerability. -Current work: the 2.0 release (GitHub issue #3), planned in `PLAN-2.0.md`. Read the work package (WP) you are assigned before you start. +Current work: the 2.0 release (GitHub issue #3), split into work packages (WPs), the sub-issues of #3. Read the WP you are assigned before you start. ## Non-negotiables @@ -90,7 +90,13 @@ If you touched secret-handling code, also complete the PR checklist below and in Standards and advisories change. At the start of each WP, and whenever you rely on a spec detail or a vulnerability claim: -1. Check the primary sources listed in `PLAN-2.0.md` §9 (FIPS 203, SP 800-227, ACVP, C2SP/Wycheproof, NVD/GHSA/RustSec, pqc-forum). +1. Check the primary sources: + - FIPS 203 (final) and errata: + - SP 800-227 (KEM recommendations): + - ACVP ML-KEM spec and vectors: , + - C2SP CCTV ML-KEM: ; Wycheproof: + - Advisories: NVD , GitHub Advisory DB , RustSec + - NIST pqc-forum: 2. Record in the PR: source URL, date checked, and what you found (including "no change"). 3. Treat blogs, news, and search-engine summaries as leads. Confirm on the primary source before acting on them, and label anything unconfirmed as such. 4. External test vectors are never committed. They're downloaded at test time from a pinned commit and checked against `tests/vectors/manifest.json` (SHA-256). Don't silently change a pin or a hash. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b7671a..540203d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +- License: PyKyber is now licensed under MIT OR Apache-2.0, at the user's option (`LICENSE-MIT`, `LICENSE-APACHE`; `license = "MIT OR Apache-2.0"` in `Cargo.toml` and `pyproject.toml`). Version 1.0.5 was released under the Unlicense, and it still is. The `LICENSE` file and the "License :: Public Domain" classifier are gone. + +- CI: removed the `every commit passes` job. The `test` job still runs on every PR and push. +- Housekeeping: removed `PLAN-2.0.md` (the work packages are the sub-issues of #3; the primary-source list moved to `AGENTS.md`), `opencode.json` and the comment-only `requirements.txt`. + +- Docs: the README is now only install, quick start, sizes, performance and the Kyber deprecation note; the rest moved to `docs/`, and the new `docs/enterprise.md` covers licensing, dependencies, supply chain, security status and support for companies. Earlier: the README is shorter and rewritten in plain prose. The benchmark (`performance-tests/benchmark.py`) now times `ML_KEM_*` as well as the Kyber classes on a release build, writes `results.json`, and draws two charts (`latency.png`, `ml_kem_vs_kyber.png`) that replace the four old ones. The old README figures were for the Kyber classes only. `make benchmark` builds the release extension first, and the chart dependency is in `performance-tests/requirements.txt`. + - Security pass over the legacy Kyber code (issue #18): the random bytes for `Kyber*` key generation and encapsulation now come from the operating system generator (`OsRng`) instead of `thread_rng()`, and a failure of that generator is raised as `RandomnessError` (a `KyberError`) instead of panicking or being retried with a weaker source. Seeded paths and all Kyber output are unchanged. - Security pass over the legacy Kyber code (issue #18), continued: optimization barriers in `poly_frommsg`, `verify` and `cmov`; decapsulation and the PRF noise buffers are wiped on every exit; secrets returned to Python are wiped on the Rust side; the Rust keypair and encapsulation functions check buffer lengths; `rand` is 0.8.6 in `Cargo.lock`. The findings, the CVE and paper table and the two decisions not to fix (Compress_11, Keccak stack spills) are in `SECURITY.md`. Kyber output is unchanged. - Behavior change, `Kyber512/768/1024.from_keys` (issue #18): it now raises `KyberError` when the public key is not the copy stored inside the secret key (a round-3 Kyber secret key is `sk_pke || pk || H(pk) || z`). Before, any public key and secret key of the right sizes were accepted, so `Kyber768.from_keys(a.public_key, b.secret_key)` built a keypair whose `decapsulate` silently returned a different secret than `encapsulate` had produced. A key pair that matches is accepted as before, and a wrong-size key keeps its existing size error. `ML_KEM_*.from_keys` is not changed. @@ -12,7 +19,7 @@ Planned release; not tagged or published yet. Adds ML-KEM (FIPS 203) beside the - 2.0 development: repo setup (issue #4), including CI on pushes to `dev-v2`, new `CONTRIBUTING.md`, root `SECURITY.md` (vulnerability-reporting policy + migrated security findings log), PR and issue templates, `CODEOWNERS` routing `src/` and `SECURITY.md` to the maintainer for review, and PR-description rules for agents in AGENTS.md (plain prose, ASCII-checked post text, REST fallback). - Legacy freeze (issue #6): golden vectors capture the byte output of pykyber v1.0.5 (append-only `tests/vectors/legacy-kyber-v1.0.5.json`, with the generator); Rust and Python tests fail on any change to seeded keygen, seeded encapsulation, or decapsulation output. -- CI (issue #7): a `test` job on Linux, macOS and Windows (oldest and newest Python) runs `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test --locked`, `maturin develop` and `pytest`; a `commits` job re-runs them on every commit of a PR via `git rebase --exec scripts/check.sh`. `requirements-dev.txt` declares maturin and pytest; wheel release now waits for `test`. +- CI (issue #7): a `test` job on Linux, macOS and Windows (oldest and newest Python) runs `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test --locked`, `maturin develop` and `pytest`. (A per-commit `every commit passes` job was added here and later removed; `scripts/check.sh` can still be run per commit with `git rebase --exec`.) `requirements-dev.txt` declares maturin and pytest; wheel release now waits for `test`. - ML-KEM (issue #13): new classes `ML_KEM_512`, `ML_KEM_768` and `ML_KEM_1024` (FIPS 203) with the same shape as `Kyber*` (an instance is a keypair; static `encapsulate`, `decapsulate`, `from_keys`; hex/b64/dict; tuple unpacking). A 64-byte seed `d || z` gives deterministic keys and is exposed as `.seed` only for such keypairs. ML-KEM methods refuse `Kyber*` objects by type (`AlgorithmMismatchError`); raw bytes cannot be told apart. New `KyberError` subclasses: `InvalidInputError`, `InvalidEncapsulationKeyError`, `InvalidDecapsulationKeyError`, `RandomnessError`, `DecapsulationError`, `AlgorithmMismatchError`. The Kyber classes are unchanged. - Kyber deprecation (issue #14): the first use of any `Kyber512/768/1024` entry point (constructor, `from_keys`, static `encapsulate` or `decapsulate`) emits one `KyberDeprecationWarning` (a `DeprecationWarning` subclass, exported from `pykyber`) per process. Importing does not warn, `ML_KEM_*` never warns, and Kyber output is unchanged. Python shows `DeprecationWarning` by default only when it is attributed to `__main__`; run with `-W default::DeprecationWarning` or call `warnings.simplefilter` to see it elsewhere. - Docs (issue #15): the README quick start, `docs/api.md` and `docs/index.md` use `ML_KEM_768` (NIST's default parameter set, FIPS 203 section 8), the Kyber classes are documented as deprecated, and the new `docs/migration.md` explains the move from Kyber to ML-KEM. The version is 2.0.0 in `Cargo.toml` and `pyproject.toml`. diff --git a/CLAUDE.md b/CLAUDE.md index 27948c8..67ac969 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,9 +6,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What this is -PyKyber: a Python package (`pykyber`) exposing round-3 CRYSTALS-Kyber, implemented from scratch in Rust (its own Keccak/FIPS 202, NTT, etc.; no external crypto crate) and bound with PyO3 + maturin (abi3, Python ≥3.8). Unlicense. Current version 1.0.5; the version lives in both `Cargo.toml` and `pyproject.toml`, so keep them in sync. +PyKyber: a Python package (`pykyber`) exposing round-3 CRYSTALS-Kyber, implemented from scratch in Rust (its own Keccak/FIPS 202, NTT, etc.; no external crypto crate) and bound with PyO3 + maturin (abi3, Python ≥3.8). MIT OR Apache-2.0 from 2.0 (version 1.0.5 was released under the Unlicense). Current version 1.0.5; the version lives in both `Cargo.toml` and `pyproject.toml`, so keep them in sync. -Active work is 2.0 (issue #3): add ML-KEM (FIPS 203) beside the legacy Kyber classes. The plan, the work packages and the verified spec facts are in `PLAN-2.0.md`. +Active work is 2.0 (issue #3): add ML-KEM (FIPS 203) beside the legacy Kyber classes. The work packages are the sub-issues of #3. ## Commands @@ -29,7 +29,7 @@ pytest python/tests/test_kyber.py::test_kyber768_new_api `make clean` deletes `target/`, the built `.so`, and the `.pyi`; rebuild afterwards. -Known baseline (2026-10-30, after WP-4 / #12 and WP-5 / #13 merged): `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test` and `pytest python/tests` all pass on `dev-v2`, and the legacy golden vectors (#6) are in. CI (`.github/workflows/CI.yml`) has a `test` job (Linux/macOS/Windows, Python 3.8 and newest) and an `every commit passes` job that runs `scripts/check.sh` over each commit of a PR via `git rebase --exec`; making them required checks on `dev-v2` is a branch-protection setting only the maintainer can change (#7). The clippy lints `needless_range_loop`, `identity_op` and `explicit_counter_loop` are allowed per module in `src/kyber/` because the legacy loops mirror the reference C code; don't add new code that needs them. CI installs the latest stable toolchain, which can be newer than the one on your machine and bring new clippy lints (rustc 1.99 added `chunks_exact_to_as_chunks`, which failed PR #28 although 1.95 was clean), so run clippy with a recent stable before pushing: `rustup toolchain install stable` and `cargo +stable clippy --all-targets -- -D warnings`. +Known baseline (2026-10-30, after WP-4 / #12 and WP-5 / #13 merged): `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test` and `pytest python/tests` all pass on `dev-v2`, and the legacy golden vectors (#6) are in. CI (`.github/workflows/CI.yml`) has a `test` job (Linux/macOS/Windows, Python 3.8 and newest); making it a required check on `dev-v2` is a branch-protection setting only the maintainer can change (#7). The clippy lints `needless_range_loop`, `identity_op` and `explicit_counter_loop` are allowed per module in `src/kyber/` because the legacy loops mirror the reference C code; don't add new code that needs them. CI installs the latest stable toolchain, which can be newer than the one on your machine and bring new clippy lints (rustc 1.99 added `chunks_exact_to_as_chunks`, which failed PR #28 although 1.95 was clean), so run clippy with a recent stable before pushing: `rustup toolchain install stable` and `cargo +stable clippy --all-targets -- -D warnings`. ## Architecture @@ -49,7 +49,7 @@ Official vectors (WP-4, merged): the manifest pins ACVP, C2SP CCTV and Wycheproo Other locations: docs are in `docs/` (`api.md`, `index.md`), the benchmark is in `performance-tests/`, and `CHANGELOG.md` is maintained by hand. -`.opencode/` (another agent tool's notes, including a `SECURITY.md` log of past fixes: constant-time division removal, Kyber-512 η1=3) is **gitignored**, so other contributors don't have it. PLAN-2.0 WP-0 migrates that log to a root `SECURITY.md`. +`.opencode/` (another agent tool's notes, including a `SECURITY.md` log of past fixes: constant-time division removal, Kyber-512 η1=3) is **gitignored**, so other contributors don't have it. That log was migrated to the root `SECURITY.md`. ## Repo state diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a23970a..a3763f9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,8 +4,8 @@ PyKyber is a post-quantum KEM library: round-3 CRYSTALS-Kyber, implemented from scratch in Rust (no third-party crypto at runtime) and exposed to Python through PyO3. A 2.0 release adds ML-KEM (FIPS 203) beside the legacy Kyber API. This file covers how to work on the repo; `AGENTS.md` is the binding -rulebook for humans *and* AI agents, and `PLAN-2.0.md` tracks the 2.0 work -packages. +rulebook for humans *and* AI agents. The 2.0 work packages are the +sub-issues of #3. ## Development setup @@ -39,8 +39,7 @@ These commands are the gate for every PR: scripts/check.sh # fmt, clippy -D warnings, cargo test, maturin develop, pytest ``` -CI runs the same checks on Linux, macOS and Windows, and again on every commit of -a PR (`git rebase --exec scripts/check.sh origin/dev-v2`). +CI runs the same checks on Linux, macOS and Windows. ## Branching and PRs @@ -48,7 +47,7 @@ All 2.0 development happens on **`dev-v2`**. The default branch is still `dev` until the 2.0 release; `main` is the stable release branch. During 2.0: - Branch from `dev-v2` as `wp-` (e.g. `wp3-mlkem-keygen`), - where `N` is the work-package number from `PLAN-2.0.md` / the linked sub-issue of #3. + where `N` is the work-package number of the linked sub-issue of #3. - Open every PR **against `dev-v2`**. Never target `main` or `dev`. - One work package (or less) per PR. Crypto PRs should stay under ~400 changed lines excluding vector files. @@ -122,6 +121,13 @@ The full rules are in `AGENTS.md`; the ones most often violated: for reading and output comparison in dev-only tests. Their code is never copied into this repo. +## Licensing + +PyKyber is licensed under MIT OR Apache-2.0, at the user's option. Unless you +say otherwise, a contribution you submit is licensed the same way, without extra +terms. Do not paste in code under another license, and never copy code from the +other implementations listed above. + ## Reporting security issues Do not open a public issue for security problems. Use GitHub's private diff --git a/Cargo.toml b/Cargo.toml index a3fbba8..9f58e78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ name = "pykyber" version = "2.0.0" edition = "2021" +license = "MIT OR Apache-2.0" [lib] name = "pykyber" diff --git a/LICENSE b/LICENSE deleted file mode 100644 index fdddb29..0000000 --- a/LICENSE +++ /dev/null @@ -1,24 +0,0 @@ -This is free and unencumbered software released into the public domain. - -Anyone is free to copy, modify, publish, use, compile, sell, or -distribute this software, either in source code form or as a compiled -binary, for any purpose, commercial or non-commercial, and by any -means. - -In jurisdictions that recognize copyright laws, the author or authors -of this software dedicate any and all copyright interest in the -software to the public domain. We make this dedication for the benefit -of the public at large and to the detriment of our heirs and -successors. We intend this dedication to be an overt act of -relinquishment in perpetuity of all present and future rights to this -software under copyright law. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR -OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, -ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -OTHER DEALINGS IN THE SOFTWARE. - -For more information, please refer to diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..efcd295 --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Yoav Haimov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile index bcb3b2d..449b871 100644 --- a/Makefile +++ b/Makefile @@ -15,4 +15,5 @@ clean: rm -rf ./target/ benchmark: + .venv/bin/maturin develop --release .venv/bin/python performance-tests/benchmark.py \ No newline at end of file diff --git a/PLAN-2.0.md b/PLAN-2.0.md deleted file mode 100644 index a44bf10..0000000 --- a/PLAN-2.0.md +++ /dev/null @@ -1,171 +0,0 @@ -# PyKyber 2.0 — implementation & security plan - -Source of truth for scope: GitHub issue #3, "[2.0] Add ML-KEM (FIPS 203) support while keeping legacy Kyber API with deprecation". This plan breaks that issue into reviewable work packages. If this plan and the issue disagree, the issue wins: raise it on the issue, don't silently diverge. - -Agents and contributors working on this plan must also follow `AGENTS.md`. - -## 1. Scope (from issue #3) - -| Issue requirement | Where it is delivered | Acceptance check | -|---|---|---| -| Add `ML_KEM_512`, `ML_KEM_768`, `ML_KEM_1024` | WP-3, WP-5 | Python classes exported; ACVP + CCTV + Wycheproof vectors pass (WP-4) | -| Keep `Kyber512/768/1024` working with old behavior | WP-1 (golden vectors), every PR | Golden vectors captured from `v1.0.5` pass unchanged in CI | -| Deprecation warning the first time a `Kyber*` class is used | WP-6 | Subprocess test: N uses -> exactly 1 warning | -| Warning only once per process | WP-6 | Same test, across all three classes and static methods | -| Docs/examples point at ML-KEM | WP-7 | README, `docs/`, examples use ML-KEM; Kyber marked deprecated | -| Security pass (recent CVEs + side channels), written up in `SECURITY.md` | WP-8 (gate items), WP-9 (full pass) | `SECURITY.md` at repo root with findings log | -| Don't break the current API | all | No removed/renamed public names; existing tests unchanged | -| Rust backend stays ours, Unlicense | all | No new **runtime** crypto dependencies; dev-only deps allowed for testing | -| Move default branch to `main` | Release | Maintainer merges `dev-v2` into `main` at the end; GitHub default = `main`, protected | -| May trade some speed for side-channel resistance | WP-8/9 | Benchmarks recorded before/after each hardening PR | - -Out of scope for 2.0: removing the Kyber classes, hybrid KEMs (X25519+ML-KEM), ML-DSA, masking against power/EM analysis. - -## 2. Spec facts (verified against FIPS 203 final text, 2026-10-29) - -Verified from . Algorithm numbers refer to that document; cite them in code comments. - -- **Parameters (Table 2/3)**: 512: k=2, η1=3, η2=2, du=10, dv=4. 768: k=3, η1=2, η2=2, du=10, dv=4. 1024: k=4, η1=2, η2=2, du=11, dv=5. Sizes (ek/dk/ct/ss): 800/1632/768/32, 1184/2400/1088/32, 1568/3168/1568/32 — identical to Kyber, so **sizes cannot tell the two apart**. -- **KeyGen_internal(d, z)** (Alg 16): `dk = dkPKE ‖ ek ‖ H(ek) ‖ z`. K-PKE.KeyGen (Alg 13) does `(ρ, σ) = G(d ‖ k)`: the one-byte k is the domain separator added in the final standard (App. C.2). -- **Encaps_internal(ek, m)** (Alg 17): `(K, r) = G(m ‖ H(ek))`, `c = K-PKE.Encrypt(ek, m, r)`. Unlike round-3 Kyber, `m` is **not** pre-hashed and `K` is **not** derived from `H(c)` (App. C.1). -- **Decaps_internal(dk, c)** (Alg 18): decrypt -> `(K', r') = G(m' ‖ h)` -> `K̄ = J(z ‖ c)` -> re-encrypt -> if `c ≠ c'` then `K' = K̄`. The reject flag is secret and **shall be destroyed**, never returned (§6.3). -- **Input checks** (§7.2, §7.3): ek type check + modulus check `ByteEncode12(ByteDecode12(ek[0:384k])) == ek[0:384k]`; ciphertext length check on **every** decaps; dk length check + hash check `H(dk[384k:768k+32]) == dk[768k+32:768k+64]`. -- **Section 3.3 requirements**: intermediate values shall be destroyed; only exceptions are the seed `(d, z)` (may be stored, treat as a secret key) and the matrix Â. `*_internal` interfaces should not be exposed to applications except for testing. RBG must be approved; KeyGen/Encaps return an error if randomness fails. No floating point. -- NIST recommends ML-KEM-768 as the default parameter set (§8). - -The current code is **round-3 Kyber** (`kem.rs`: `m` is hashed, `H(c)` goes into the KDF, `G(d)` has no k byte), so ML-KEM gets a separate code path. - -## 3. How we work (multi-developer) - -- **Tracking**: GitHub milestone `2.0.0`; one sub-issue per work package below, linked from #3. Labels to add: `ml-kem`, `security`, `legacy-kyber`, `ci`. -- **Branches**: all 2.0 work goes into `dev-v2` (cut from `dev`). Protect it: PR required, 1 approving review, required status checks, no force push, linear history. Feature branches are cut from `dev-v2` as `wp-` (e.g. `wp3-mlkem-keygen`), and every PR targets `dev-v2`. Nobody merges into `main` or `dev` during 2.0. When everything is done, the maintainer opens the single `dev-v2` -> `main` PR, makes `main` the default branch and retires `dev`. -- **PR size**: one work package per PR or smaller; crypto PRs ≤ ~400 changed lines excluding vectors. No mixed refactor + behavior changes in one PR. -- **Commits**: Conventional Commits, matching existing history (`feat:`, `fix:`, `test:`, `ci:`, `docs:`, `security:`, `refactor:`). Every commit in a PR must build and pass the tests on its own. Fixes found in review or CI are folded into the commit that introduced the problem (`git commit --fixup` + `git rebase --autosquash`), never added as separate "fix" commits. Branches are rebased on `dev-v2`, never merged with it. -- **Merging**: PRs are **rebase-merged** into `dev-v2`, so the reviewed commits land unchanged. Squash and merge commits are turned off in repo settings (WP-0). -- **Reviews**: anything under `src/` that touches secret data needs a reviewer to tick the PR template's constant-time checklist. `CODEOWNERS` routes `src/` and `SECURITY.md` to the maintainer. -- **Changelog**: every user-visible PR adds a line under `## Unreleased` in `CHANGELOG.md`. -- **Sources**: each WP starts with a dated source check (Section 9), recorded in the PR description: what was checked, date, and result ("no change" counts). -- **Agents**: agent-written PRs follow `AGENTS.md` and get the same review as human PRs. Agents never merge. - -## 4. Work packages - -Dependencies: WP-0 -> WP-1 -> (WP-2, WP-3) -> WP-4 -> WP-5 -> (WP-6, WP-7) -> WP-8 -> release. WP-9 runs in parallel from WP-1 onward. - -### WP-0 — Repo hygiene & collaboration setup -- Protect `dev-v2` (rules in Section 3). In repo settings allow only rebase merging (turn off squash and merge commits). Add `dev-v2` to the `push` triggers in `CI.yml` (currently `main` + tags; `pull_request` already covers PRs into any branch). Switching the default branch to `main` waits for the release. -- Add `CONTRIBUTING.md` (build/test commands, branch/commit rules, how to run vectors), `.github/pull_request_template.md` (with security checklist from `AGENTS.md`), `.github/CODEOWNERS`, issue templates (bug, feature, security -> redirect to private advisory). -- Add root `SECURITY.md` with a reporting policy (GitHub private vulnerability reporting enabled) and migrate the findings log from `.opencode/SECURITY.md`, which is **gitignored** and so invisible to other contributors. -- Track `AGENTS.md`/`CLAUDE.md`; keep `.opencode/` ignored. - -### WP-1 — Green baseline + legacy freeze -- Fix `tests/unit_params.rs::test_kyber_1024_params` (expects 1280, gets 1408). Decide whether the test or the constant is wrong **by checking the Kyber round-3 spec**, not by editing the assertion to match. -- Add `pytest` (+ `maturin`) as dev deps (`requirements-dev.txt` or a `[dependency-groups]` table); `.venv` currently lacks pytest. -- **Golden vectors**: install `pykyber==1.0.5` from PyPI in a clean venv; for fixed seeds, record keygen outputs (seeded), decaps outputs for fixed ciphertexts, and encaps outputs where determinism allows (encaps currently has no seed in Python, so record via the Rust `crypto_kem_enc(.., Some(seed))` path at tag `v1.0.5`). Store as `tests/vectors/legacy-kyber-v1.0.5.json` with the generator script. These files are append-only. -- CI: add a `test` job (Linux, macOS, Windows; one old and one current Python) running `cargo fmt --check`, `cargo clippy -- -D warnings` (fix or explicitly `allow` the current 59 diagnostics in a separate PR first), `cargo test`, `maturin develop` + `pytest`. Make it a required check. - -### WP-2 — Shared-primitive audit (prep for reuse) -- Decide which legacy modules ML-KEM can share unchanged: `fips202`, `ntt`, `reduce`, `cbd`, poly (de)serialization, compress. Rule: shared code may be **hardened** (constant-time fixes) only if legacy golden vectors still pass byte-for-byte. -- Introduce a generic `const K: usize` (or param-trait) poly-vector type for the new code instead of a fourth set of `_512/_768/_1024` copies. Legacy duplicated code stays as is. - -### WP-3 — ML-KEM core in Rust (`src/mlkem/`) -- Implement Alg 13–18 (K-PKE KeyGen/Encrypt/Decrypt, KeyGen/Encaps/Decaps internal) and Alg 19–21 wrappers, generic over the parameter set. -- Input checks per §7.2/§7.3 return typed errors, and they run before any secret-dependent work. -- Visibility: `*_internal` functions are `pub(crate)`; expose them only under `#[cfg(any(test, feature = "acvp"))]` for vector tests (§3.3). Public Rust API: `keygen()`, `keygen_from_seed(d, z)`, `encaps(ek)`, `decaps(dk, c)`. -- Randomness: `OsRng`/`getrandom`; errors propagate (Alg 19/20 return ⊥). No `thread_rng()` for key material in new code. -- Secrets: all intermediates (`m`, `r`, `K̄`, `σ`, noise vectors, the reject flag) zeroized before return; use `Zeroizing<..>` wrappers. -- Constant-time: re-encryption compare and `K'`/`K̄` select are branch-free with an optimization barrier (in-house, no new runtime crate); no division/modulo on secrets; no secret-indexed table lookups. - -### WP-4 — Official vectors (gate for "passes the official test vectors") -- NIST ACVP-Server `gen-val/json-files/ML-KEM-keyGen-FIPS203` and `ML-KEM-encapDecap-FIPS203` (internalProjection + expectedResults), including decapsulation-key-check and encapsulation-key-check groups. -- C2SP CCTV ML-KEM: modulus-check negatives, "unlucky" XOF vectors, and the accumulated/strcmp vectors. -- Wycheproof ML-KEM test files. -- External vector files are **not committed**. A fetch script downloads them at test time from a pinned commit and checks each file against a SHA-256 in `tests/vectors/manifest.json`; CI caches the downloads and the folder is gitignored. The repo only holds our own code, our parsers, and our own legacy golden vectors. -- Dev-only differential fuzz against an independent implementation (RustCrypto `ml-kem` or `libcrux-ml-kem` as a `[dev-dependencies]` entry). Never a runtime dependency. - -### WP-5 — Python bindings & classes -- `src/lib.rs`: `_mlkem_{512,768,1024}_{keygen,encaps,decaps}`; map Rust errors to `KyberError` subclasses (not bare `PyException`). -- `python/pykyber/`: `ML_KEM_512/768/1024` mirroring the `Kyber*` surface (instance = keypair; static `encapsulate`/`decapsulate`/`from_keys`; serialization; tuple unpacking). Seed = 64 bytes `d ‖ z` (Alg 19 order). Add `.seed` support only if keygen came from a seed, and document that it is secret. -- **Mix-up resistance**: since sizes are identical, results/keys carry their algorithm; `ML_KEM_768.decapsulate` refuses a `Kyber768` keypair object (raw bytes can't be distinguished, so say so in the docs). -- Regenerate `.pyi` stubs; type hints; `__all__` updated. - -### WP-6 — Kyber deprecation -- First use of any `Kyber*` entry point (constructor, `from_keys`, static `encapsulate`/`decapsulate`) emits one warning per process, guarded by a module-level flag. Import alone does not warn. -- Category: `KyberDeprecationWarning(DeprecationWarning)` so users can filter it precisely. Note: Python hides `DeprecationWarning` by default unless it's attributed to `__main__`; use `stacklevel` so it points to user code. Staying quiet is deliberate for now (D2). -- Message says that Kyber (round 3) and ML-KEM are **not interoperable** and that switching changes the shared secret, and links the migration guide. -- Tests in a subprocess: warning count == 1 across mixed classes and methods; outputs unchanged (golden vectors). - -### WP-7 — Docs -- README quick-start and `docs/api.md` switch to `ML_KEM_768` (NIST's default). Add a "Kyber -> ML-KEM migration" page: not interoperable, both sides must upgrade, seed format, key sizes the same. -- CHANGELOG `2.0.0` entry; version bump in **both** `Cargo.toml` and `pyproject.toml` (plus the docs link in `pyproject.toml`). - -### WP-8 — Security release gate (required before 2.0.0) -Minimum security work on the new code before tagging: -- Constant-time review checklist (see `AGENTS.md`) signed off on `src/mlkem/` and any shared primitives it uses. -- Assembly check on release builds for `x86_64` and `aarch64` (and a spot check on `armv7`/`x86` 32-bit, which CI ships): no secret-dependent branches and no `div`/`udiv`/`sdiv` in decaps, `poly_tomsg`/compress, `poly_frommsg`, select/compare. Script it with `cargo asm` or `objdump` so it can run again. -- Dynamic timing test (dudect-style, or valgrind "secret = undefined" à la ctgrind) on decaps with valid vs. invalid ciphertexts. -- `cargo audit` + `cargo deny` clean; CI actions pinned by SHA. -- Fuzz targets (`cargo fuzz`) for ek/dk/ct parsing and decaps run ≥ 1 CPU-hour each without panics. -- `SECURITY.md` updated with the results. - -### WP-9 — Full security pass (parallel; may continue after 2.0 as 2.0.x) -The issue comment allows this to land after the version release. Track each finding as its own issue/PR and log it in `SECURITY.md`. -- Legacy code, same checklist as WP-8. Starting points found while writing this plan (unconfirmed, need assembly or dynamic evidence): - - `src/kyber/poly.rs` `poly_frommsg`: `((msg >> j) & 1).wrapping_neg()` mask is the CVE-2024-37880 ("Clangover") pattern that compilers have turned into a branch. - - `poly_tomsg` uses the multiply-shift replacement for `/q` (KyberSlash fix); verify all compress paths (`poly_compress`, `polyvec_compress*`) do too. - - `verify.rs` `verify`/`cmov`: check for optimization barriers. - - `api.rs` `DummyRng` panics by design; confirm no path from Python reaches it, and that panics can't cross the PyO3 boundary. - - `lib.rs` uses `thread_rng()` for key material; compare with `OsRng`. - - Decaps doesn't zeroize every intermediate; Python `bytes` copies of secrets can't be wiped (document this). -- Review against each CVE/paper in Section 9 and record "affected / not affected / mitigated" with evidence. -- Any change to legacy code must keep golden vectors byte-identical. - -## 5. Release checklist (2.0.0) - -- [ ] All issue #3 "Done when" items ticked with links to the proving test/CI run -- [ ] WP-8 gate complete; `SECURITY.md` current -- [ ] Release candidate `2.0.0rc1` on TestPyPI; install the wheels from **every** CI target and run the test suite against them (not only the build) -- [ ] Source re-check (Section 9) done within 7 days of the tag -- [ ] Maintainer opens the `dev-v2` -> `main` PR and merges it; `main` becomes the default branch; `dev` is retired -- [ ] Update `pyproject.toml` URLs and doc links that point at `dev` or `v1.0.5` -- [ ] Tag `v2.0.0` from `main`, publish, GitHub release notes link the migration guide - -## 6. Open decisions for the maintainer - -- **D1 (decided)**: external test vectors are downloaded at test time with pinned hashes, never committed. Other implementations are for reference and output comparison only; no third-party code is copied into the repo. -- **D2 (decided)**: quiet `DeprecationWarning` for now (via a `KyberDeprecationWarning` subclass), plus a note in the README. -- **D3**: hold 2.0.0 for any WP-9 findings, or ship with WP-8 only and follow up in 2.0.x (the issue comment suggests the latter). -- **D4**: whether to expose ML-KEM in the public Rust crate API now or keep the crate Python-only for 2.0. - -## 7. Risks - -- Shared-primitive changes silently altering legacy output -> golden vectors are a required CI check. -- Compiler-introduced timing leaks (KyberSlash, Clangover) -> assembly check is scripted and re-run per release and per Rust toolchain bump. -- Key confusion between Kyber and ML-KEM (same sizes) -> typed objects, docs, distinct class names in errors. -- Test-vector schema drift (ACVP revisions such as FIPS203-tr1 seed-form dk) -> pin the commit SHA; re-check at release. - -## 8. Definition of done (per PR) - -`cargo fmt --check`, `cargo clippy -D warnings`, `cargo test`, `pytest` green **on every commit** in the PR; review fixes folded into their commits and the branch rebased on `dev-v2`; golden vectors untouched and passing; new behavior has tests (vectors for crypto); CHANGELOG line; source check recorded; constant-time checklist ticked if secrets were touched. - -## 9. Source checklist (re-check at the start of every WP and before release) - -Record the date and result in the PR. Treat secondary sources (news, blogs) as leads, not facts. - -Primary: -- FIPS 203 (final) and errata: -- SP 800-227 (KEM recommendations, referenced by FIPS 203 §3.3): -- ACVP ML-KEM spec: -- ACVP-Server vectors: -- C2SP CCTV ML-KEM: -- Wycheproof: -- NVD search ("ML-KEM", "Kyber", "mlkem"): -- GitHub Advisory DB (pip, rust): ; RustSec: -- NIST pqc-forum: - -Known issues to assess against this code base: -- KyberSlash 1/2, division timing (TCHES 2025): , -- CVE-2024-37880, `poly_frommsg` compiler-induced branch: -- 2026 items found on 2026-10-29, **not yet confirmed on NVD**: CVE-2026-115408 (crypto11 ML-KEM key-pair lookup binding, via OSV/Vulners ); CVE-2026-6330 (wolfSSL ML-KEM bug, mentioned in ). Both look like API/integration bugs in other libraries; check whether the same class of mistake applies to our `from_keys` (binding a pk to the wrong sk). -- 2026 side-channel research on ML-KEM keygen/encoding (SEALSQ, LightSec 2026; news only so far): -- Airtight side-channel testing framework (AFIT): -- Reference implementations to compare against: Go `crypto/mlkem`, BoringSSL, RustCrypto `ml-kem`, Cryspen `libcrux` (formally verified). diff --git a/README.md b/README.md index 2464604..bab61d4 100644 --- a/README.md +++ b/README.md @@ -1,162 +1,52 @@ # PyKyber -A Python library for post-quantum key encapsulation, implemented in Rust: ML-KEM (FIPS 203), plus the legacy round-3 CRYSTALS-Kyber classes, which are deprecated. +Post-quantum key encapsulation for Python: ML-KEM (FIPS 203), written in Rust with no third-party crypto crate and no Python dependencies. Licensed MIT OR Apache-2.0. -ML-KEM and round-3 Kyber are not interoperable. If you are moving from Kyber, read the [migration guide](docs/migration.md) first. - -## Installation +## Install ```bash pip install pykyber ``` -## Quick Start - -```python -import pykyber - -# Generate a keypair (Alice) -alice_keypair = pykyber.ML_KEM_768() - -# Encapsulate - create shared secret (Bob) -bob_result = pykyber.ML_KEM_768.encapsulate(alice_keypair.public_key) - -# Decapsulate - recover shared secret (Alice) -shared_secret = alice_keypair.decapsulate(bob_result.ciphertext) - -# Both parties now share the same secret -print(f"Match: {bob_result.shared_secret == shared_secret}") -``` - -ML-KEM-768 is the parameter set NIST recommends as the default (FIPS 203, section 8). +Python 3.8 or newer. Prebuilt wheels cover Linux (glibc and musl), macOS and Windows. -## API Usage +## Quick start ```python import pykyber -# Create a keypair - instant generation on class instantiation -keypair = pykyber.ML_KEM_512() # security category 1 -keypair = pykyber.ML_KEM_768() # security category 3 (NIST default) -keypair = pykyber.ML_KEM_1024() # security category 5 - -# Deterministic key generation from a 64-byte seed d || z (the seed is secret) -seed = bytes(range(64)) -keypair = pykyber.ML_KEM_768(seed=seed) -assert keypair.seed == seed # None for randomly generated keypairs - -# Access raw key bytes -public_key = keypair.public_key # bytes -secret_key = keypair.secret_key # bytes - -# Or unpack directly as tuple -public_key, secret_key = keypair # same as above - -# Encapsulate - create ciphertext and shared secret -result = keypair.encapsulate() -# result.ciphertext - bytes to send to receiver -# result.shared_secret - 32 bytes shared secret - -# Serialization and Utilities -hex_pk = keypair.public_key_hex # Get hex representation -b64_sk = keypair.secret_key_b64 # Get base64 representation -data = keypair.to_dict() # Get dictionary of hex keys - -# Encapsulation results also support serialization -ct_hex = result.ciphertext_hex -ss_b64 = result.shared_secret_b64 - -# Or unpack directly as tuple -ciphertext, shared_secret = keypair.encapsulate() # same as above - -# Decapsulate - recover shared secret from ciphertext -shared_secret = keypair.decapsulate(result.ciphertext) - -# Static methods - use without creating a keypair instance - -# Encapsulate with just a public key -result = pykyber.ML_KEM_768.encapsulate(public_key) - -# Decapsulate with just ciphertext and secret key -shared_secret = pykyber.ML_KEM_768.decapsulate(result.ciphertext, secret_key) - -# Load a Keypair object from existing keys -keypair = pykyber.ML_KEM_768.from_keys(public_key, secret_key) +alice = pykyber.ML_KEM_768() # Alice makes a keypair +bob = pykyber.ML_KEM_768.encapsulate(alice.public_key) # Bob encapsulates against her public key +secret = alice.decapsulate(bob.ciphertext) # Alice recovers the same 32-byte secret +assert secret == bob.shared_secret ``` -## Key Sizes - -| Variant | Public Key | Secret Key | Ciphertext | Shared Secret | -|-------------|------------|------------|------------|---------------| -| ML-KEM-512 | 800 bytes | 1632 bytes | 768 bytes | 32 bytes | -| ML-KEM-768 | 1184 bytes | 2400 bytes | 1088 bytes | 32 bytes | -| ML-KEM-1024 | 1568 bytes | 3168 bytes | 1568 bytes | 32 bytes | +`ML_KEM_512`, `ML_KEM_768` (NIST's default) and `ML_KEM_1024` work the same way. -The legacy Kyber classes use the same sizes, so a key or ciphertext cannot be identified as Kyber or ML-KEM from its length. - -## Error Handling - -All operations raise `KyberError`, or one of its subclasses, when invalid input is provided: - -```python -import pykyber - -try: - pykyber.ML_KEM_768.encapsulate(b"too_short") -except pykyber.KyberError as e: - print(e) -# Output: Invalid public_key for ML-KEM-768.encapsulate: expected 1184 bytes, got 9. -``` - -Common error cases: -- Invalid key, ciphertext or seed size: `InvalidInputError` -- Encapsulation key that fails the FIPS 203 section 7.2 check: `InvalidEncapsulationKeyError` -- Decapsulation key that fails the FIPS 203 section 7.3 check: `InvalidDecapsulationKeyError` -- An object of another algorithm: (a `Kyber*` keypair passed to an `ML_KEM_*` method, or another ML-KEM parameter set): `AlgorithmMismatchError` - -A well-formed ciphertext that was not made for the key does not raise; decapsulation returns a different, pseudorandom secret (implicit rejection, FIPS 203). - -## Deprecated: Kyber - -`Kyber512`, `Kyber768` and `Kyber1024` implement round-3 CRYSTALS-Kyber. They still work and their output has not changed from 1.0.5, but they are deprecated in favor of ML-KEM: the first use of one in a process emits a `KyberDeprecationWarning` (a `DeprecationWarning`, which Python hides by default unless it is raised from `__main__`; run with `-W default::DeprecationWarning` to see it). The [migration guide](docs/migration.md) explains how to move. - -## Security status - -The ML-KEM code is checked against the official ACVP, C2SP CCTV and Wycheproof test vectors and against an independent implementation (a dev-only differential test). It has not been independently audited, and the side-channel review planned for 2.0 has not been completed, so this README makes no constant-time claim. Report vulnerabilities as described in [SECURITY.md](SECURITY.md). Secret keys and shared secrets are returned as immutable Python `bytes` that cannot be wiped from memory. +## Sizes +| Class | Public key | Secret key | Ciphertext | Shared secret | +|-------|------------|------------|------------|---------------| +| `ML_KEM_512` | 800 bytes | 1632 bytes | 768 bytes | 32 bytes | +| `ML_KEM_768` | 1184 bytes | 2400 bytes | 1088 bytes | 32 bytes | +| `ML_KEM_1024` | 1568 bytes | 3168 bytes | 1568 bytes | 32 bytes | ## Performance -Performance benchmarks of the legacy Kyber classes (100 iterations each; the benchmark has not been rerun for ML-KEM): +Time per call from Python on an Intel Core i7-7700 (Python 3.14, release build, median of 7 rounds of 500 calls). One machine, one run. -| Variant | Keypair | Encapsulate | Decapsulate | -|---------|---------|-------------|-------------| -| Kyber512 | 0.44 ms (2,262/s) | 0.58 ms (1,721/s) | 0.71 ms (1,404/s) | -| Kyber768 | 0.76 ms (1,312/s) | 0.90 ms (1,111/s) | 1.08 ms (930/s) | -| Kyber1024 | 1.08 ms (928/s) | 1.35 ms (743/s) | 1.55 ms (645/s) | +| Class | Key generation | Encapsulation | Decapsulation | +|-------|----------------|---------------|---------------| +| `ML_KEM_512` | 0.031 ms | 0.037 ms | 0.045 ms | +| `ML_KEM_768` | 0.052 ms | 0.058 ms | 0.071 ms | +| `ML_KEM_1024` | 0.082 ms | 0.089 ms | 0.106 ms | -Run benchmarks and generate graphs: - -```bash -make benchmark -``` +![ML-KEM time per operation](performance-tests/latency.png) -Generated graphs: +## More -### Performance Comparison -![Performance Comparison](performance-tests/performance_comparison.png) +Everything else is in [docs/](docs/index.md): the [API reference](docs/api.md), the [guide for teams adopting PyKyber](docs/enterprise.md) (licensing, supply chain, security status, upgrade policy), and [SECURITY.md](SECURITY.md) for reporting vulnerabilities. -### Operation Breakdown -![Operation Breakdown](performance-tests/operation_breakdown.png) - -### Scalability -![Scalability](performance-tests/scalability.png) - -### Throughput -![Throughput](performance-tests/throughput.png) - - -## License +## Deprecated: Kyber -This is free and unencumbered software released into the public domain. -See [LICENSE](LICENSE) or . +`Kyber512`, `Kyber768` and `Kyber1024` (round-3 CRYSTALS-Kyber) still work and give the same output as 1.0.5, but they are deprecated and emit a `KyberDeprecationWarning`. They are not interoperable with ML-KEM. See the [migration guide](docs/migration.md). diff --git a/SECURITY.md b/SECURITY.md index e694f23..32e1890 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -26,7 +26,7 @@ What helps us triage fastest: ## Response and disclosure -- This is an independently maintained, unfunded, public-domain project: we +- This is an independently maintained, unfunded, open-source project: we acknowledge reports on a best-effort basis, aiming for a few days. - We prefer coordinated disclosure. We will keep you informed, credit you in the advisory if you wish (including anonymously is fine), and publish the @@ -53,8 +53,7 @@ pykyber; social engineering. Audits and hardening work performed on this codebase are logged here so the history of known issues and their resolutions stays visible to every -contributor. Ongoing 2.0 security work is planned in `PLAN-2.0.md` -(WP-8/WP-9) and tracked issue-by-issue. +contributor. Ongoing 2.0 security work is tracked issue-by-issue. The log below was migrated on 2026-10-29 from `.opencode/SECURITY.md`, a maintainer/agent working log that was @@ -288,7 +287,7 @@ to the maintainer. `zeroize`, `rand_core`, `getrandom`, `rand_chacha`: none. - Ciphertext compare covers the full ciphertext (see CVE-2026-6330 below). - KyberSlash scan: see the table. -## CVE and paper review (PLAN-2.0.md section 9) +## CVE and paper review Dates are 2026-10-30. "Primary" means the issuing page or the paper itself. diff --git a/docs/enterprise.md b/docs/enterprise.md new file mode 100644 index 0000000..83420cb --- /dev/null +++ b/docs/enterprise.md @@ -0,0 +1,44 @@ +# Using PyKyber in a company + +What a security, legal or platform team usually asks before approving a dependency, with the facts as they stand. If a statement here is not true of the release you run, the release is wrong, so tell us. + +## License + +MIT OR Apache-2.0, at your option ([LICENSE-MIT](../LICENSE-MIT), [LICENSE-APACHE](../LICENSE-APACHE)). Both are permissive and allow commercial and closed-source use. Apache-2.0 includes an explicit patent grant for contributions. Version 1.0.5 was released under the Unlicense, and it still is. + +## What it depends on + +- Python: no runtime dependencies. The wheel is a single compiled extension plus a small pure-Python wrapper. +- Rust, compiled into the wheel: `pyo3`, `zeroize`, `rand` and `rand_core`. All cryptography (Keccak/SHAKE, the NTT, ML-KEM itself) is in this repository, with no third-party crypto crate. +- Dev-only crates (test vectors, a reference ML-KEM for differential tests) are never linked into the wheel. +- `Cargo.lock` is committed and CI runs the Rust tests with `--locked`, so a given commit resolves to the same dependency versions. + +## Platforms + +Python 3.8 or newer (one abi3 wheel per platform). CI builds wheels for Linux (glibc and musl; x86_64, x86, aarch64, armv7, s390x, ppc64le), macOS (Intel and Apple silicon), Windows (x64, x86, arm64) and Android, plus an sdist. Tests run on Linux, macOS and Windows. + +## Supply chain + +- Release wheels are built in GitHub Actions and carry a build-provenance attestation (`actions/attest-build-provenance`). Verify a downloaded file with `gh attestation verify --repo Zikithezikit/pykyber`. +- Pin the version and, if you can, the hash: `pip install --require-hashes -r requirements.txt` with `pykyber== --hash=sha256:...`. +- Building from the sdist needs a stable Rust toolchain and `maturin`. + +## Security status, stated plainly + +- Tested against the official NIST ACVP vectors, the C2SP CCTV vectors and the Wycheproof vectors, and against an independent implementation in a development-only differential test. CI runs these on every commit. +- Not independently audited. Not FIPS 140-3 (CMVP) validated, so it cannot by itself satisfy a requirement for a validated module. +- No constant-time claim. Secret-dependent branches and divisions were reviewed and removed, but that is evidence, not proof. [SECURITY.md](../SECURITY.md) lists what was checked, what was fixed and what is still open (for example stack copies left by the Keccak permutation). +- Secrets are returned to Python as immutable `bytes`, which cannot be wiped from memory. +- Key and ciphertext inputs are length- and format-checked (including the FIPS 203 section 7.2 and 7.3 checks) before any secret-dependent work, and errors are typed (`KyberError` and subclasses). Keys come from the operating system random generator, and a failure is raised, never retried with a weaker source. + +If a standard or contract requires an audited or validated module, treat PyKyber as an input to your own assessment, not a replacement for one. + +## Reporting and response + +Report vulnerabilities privately as described in [SECURITY.md](../SECURITY.md). PyKyber is independently maintained and unfunded, so there is no support contract or response-time guarantee: reports are acknowledged on a best-effort basis, aiming for a few days, and disclosure is coordinated. Fixes are logged in [CHANGELOG.md](../CHANGELOG.md) and in the findings log. + +## Versioning and deprecation + +- Version numbers follow semantic versioning, and breaking changes are listed in the changelog. +- The legacy `Kyber*` classes are frozen: their output is checked byte for byte against 1.0.5 in CI, and they warn on first use. They are not interoperable with ML-KEM; see the [migration guide](migration.md). +- A hybrid construction (for example X25519 combined with ML-KEM) is not provided. Build it on top if your policy requires one. diff --git a/docs/index.md b/docs/index.md index 7789966..77ad65b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,6 +1,7 @@ # pykyber docs - [API Reference](api.md): full class and function documentation (ML-KEM, and the deprecated Kyber classes) +- [Using PyKyber in a company](enterprise.md): licensing, dependencies, supply chain, security status, support and versioning - [Kyber to ML-KEM migration](migration.md): what changes when you switch, and why both sides must switch together ## Quick links @@ -8,4 +9,4 @@ - [README](../README.md): installation and quick start - [CHANGELOG](../CHANGELOG.md): release history - [SECURITY](../SECURITY.md): reporting policy and findings log -- [LICENSE](../LICENSE): public domain (Unlicense) +- [LICENSE-MIT](../LICENSE-MIT) and [LICENSE-APACHE](../LICENSE-APACHE): use either one (version 1.0.5 was released under the Unlicense) diff --git a/opencode.json b/opencode.json deleted file mode 100644 index e9aa400..0000000 --- a/opencode.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "$schema": "https://opencode.ai/config.json", - "instructions": ["CLAUDE.md"] -} diff --git a/performance-tests/benchmark.py b/performance-tests/benchmark.py index e50c5b2..fdd013b 100644 --- a/performance-tests/benchmark.py +++ b/performance-tests/benchmark.py @@ -1,238 +1,246 @@ #!/usr/bin/env python3 -"""Performance benchmarking and graphing script for pykyber. +"""Time ML-KEM and the legacy Kyber classes, then draw the charts in the README. -This script runs the performance tests from python/tests/test_kyber.py -and generates graphs from the results. -""" +Run it against a release build, because a debug build is many times slower: -import subprocess -import re -import matplotlib.pyplot as plt + maturin develop --release + python performance-tests/benchmark.py +It writes results.json, latency.png and ml_kem_vs_kyber.png next to itself and +prints the table that the README shows. Needs matplotlib (see requirements-dev.txt). +""" -def run_performance_tests(): - """Run pytest performance tests and parse results.""" - result = subprocess.run( - ['.venv/bin/python', '-m', 'pytest', - 'python/tests/test_kyber.py', - '-v', '-k', 'performance', - '--tb=no', '-q', '-s'], - capture_output=True, - text=True - ) - - output = result.stdout + result.stderr - - results = { - 'Kyber512': {}, - 'Kyber768': {}, - 'Kyber1024': {} +import json +import platform +import statistics +import sys +import time +import warnings +from pathlib import Path + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt # noqa: E402 + +import pykyber # noqa: E402 + +HERE = Path(__file__).resolve().parent + +# Each round times ITERATIONS calls and the median of ROUNDS rounds is reported, +# so one slow round (a scheduler hiccup, a frequency change) does not decide the number. +ITERATIONS = 500 +ROUNDS = 7 +WARMUP_CALLS = 200 + +LEVELS = ("512", "768", "1024") +OPERATIONS = ("keypair", "encapsulate", "decapsulate") +OPERATION_LABELS = { + "keypair": "Key generation", + "encapsulate": "Encapsulation", + "decapsulate": "Decapsulation", +} +FAMILIES = {"ML-KEM": "ML_KEM_{}", "Kyber": "Kyber{}"} + +NS_PER_MS = 1_000_000 +MS_PER_S = 1_000 + +# Okabe-Ito blue, vermillion and green. They pass scripts/validate_palette.js from the +# dataviz skill on a white surface (colour-blind separation and 3:1 contrast). +OPERATION_COLORS = {"keypair": "#0072B2", "encapsulate": "#D55E00", "decapsulate": "#009E73"} +INK = "#1f2328" +MUTED = "#59636e" +GRID = "#d1d9e0" +DPI = 150 +# Opacity of the Kyber bars, so they read as the secondary series next to ML-KEM. +KYBER_ALPHA = 0.45 + + +def time_rounds(call): + """Milliseconds per call for each round.""" + for _ in range(WARMUP_CALLS): + call() + rounds = [] + for _ in range(ROUNDS): + start = time.perf_counter_ns() + for _ in range(ITERATIONS): + call() + rounds.append((time.perf_counter_ns() - start) / ITERATIONS / NS_PER_MS) + return rounds + + +def bench_class(cls): + """Time one class through its public API, with static calls for the receiver steps.""" + keypair = cls() + public_key = keypair.public_key + secret_key = keypair.secret_key + ciphertext = cls.encapsulate(public_key).ciphertext + calls = { + "keypair": lambda: cls(), + "encapsulate": lambda: cls.encapsulate(public_key), + "decapsulate": lambda: cls.decapsulate(ciphertext, secret_key), } - - pattern = r'Kyber(\d+)\s+(keypair generation|encapsulation|decapsulation|full key exchange.*?):\s+([\d.]+)\s+ms/op' - - for match in re.finditer(pattern, output): - variant = f"Kyber{match.group(1)}" - operation = match.group(2).strip() - time_ms = float(match.group(3)) - - if 'keypair generation' in operation: - results[variant]['keypair'] = time_ms - elif 'encapsulation' in operation: - results[variant]['encapsulate'] = time_ms - elif 'decapsulation' in operation: - results[variant]['decapsulate'] = time_ms - elif 'class API' in operation: - results[variant]['class_api'] = time_ms - elif 'static methods' in operation: - results[variant]['static_methods'] = time_ms - + return {op: time_rounds(calls[op]) for op in OPERATIONS} + + +def run(): + results = {} + with warnings.catch_warnings(): + warnings.simplefilter("ignore") # the Kyber classes warn once per process + for pattern in FAMILIES.values(): + for level in LEVELS: + name = pattern.format(level) + print(f"timing {name} ...", flush=True) + rounds = bench_class(getattr(pykyber, name)) + results[name] = { + op: { + "median_ms": statistics.median(values), + "min_ms": min(values), + "max_ms": max(values), + } + for op, values in rounds.items() + } return results -def create_comparison_chart(results): - """Create a comparison chart across all variants and operations.""" - variants = ['Kyber512', 'Kyber768', 'Kyber1024'] - - x = range(len(variants)) - width = 0.25 - - fig, ax = plt.subplots(figsize=(12, 6)) - - keypair_times = [results[v]['keypair'] for v in variants] - encapsulate_times = [results[v]['encapsulate'] for v in variants] - decapsulate_times = [results[v]['decapsulate'] for v in variants] - - bars1 = ax.bar([i - width for i in x], keypair_times, width, label='Keypair Generation', color='#2ecc71') - bars2 = ax.bar(x, encapsulate_times, width, label='Encapsulation', color='#3498db') - bars3 = ax.bar([i + width for i in x], decapsulate_times, width, label='Decapsulation', color='#e74c3c') - - ax.set_xlabel('Kyber Variant', fontsize=12) - ax.set_ylabel('Time (ms)', fontsize=12) - ax.set_title('PyKyber Performance Comparison', fontsize=14, fontweight='bold') - ax.set_xticks(x) - ax.set_xticklabels(variants) - ax.legend() - ax.grid(axis='y', alpha=0.3) - - for bars in [bars1, bars2, bars3]: - for bar in bars: - height = bar.get_height() - ax.annotate(f'{height:.2f}', - xy=(bar.get_x() + bar.get_width() / 2, height), - xytext=(0, 3), - textcoords="offset points", - ha='center', va='bottom', fontsize=8) - - plt.tight_layout() - plt.savefig('performance-tests/performance_comparison.png', dpi=150) - print("Saved: performance-tests/performance_comparison.png") - plt.close() - - -def create_operation_breakdown(results): - """Create individual charts for each operation.""" - variants = ['Kyber512', 'Kyber768', 'Kyber1024'] - colors = ['#2ecc71', '#3498db', '#e74c3c'] - - fig, axes = plt.subplots(1, 3, figsize=(15, 5)) - - operations = ['keypair', 'encapsulate', 'decapsulate'] - titles = ['Keypair Generation', 'Encapsulation', 'Decapsulation'] - - for idx, (op, title) in enumerate(zip(operations, titles)): - times = [results[v][op] for v in variants] - bars = axes[idx].bar(variants, times, color=colors) - axes[idx].set_title(title, fontsize=12, fontweight='bold') - axes[idx].set_ylabel('Time (ms)') - axes[idx].grid(axis='y', alpha=0.3) - - for bar in bars: - height = bar.get_height() - axes[idx].annotate(f'{height:.2f}', - xy=(bar.get_x() + bar.get_width() / 2, height), - xytext=(0, 3), - textcoords="offset points", - ha='center', va='bottom', fontsize=10) - - plt.tight_layout() - plt.savefig('performance-tests/operation_breakdown.png', dpi=150) - print("Saved: performance-tests/operation_breakdown.png") - plt.close() - - -def create_scalability_chart(results): - """Create a chart showing how performance scales with security level.""" - variants = ['Kyber512', 'Kyber768', 'Kyber1024'] - security_levels = ['~AES-128', '~AES-192', '~AES-256'] - - keypair_times = [results[v]['keypair'] for v in variants] - encapsulate_times = [results[v]['encapsulate'] for v in variants] - decapsulate_times = [results[v]['decapsulate'] for v in variants] - - fig, ax = plt.subplots(figsize=(10, 6)) - - x = range(len(variants)) - - ax.plot(x, keypair_times, 'o-', linewidth=2, markersize=8, label='Keypair Generation', color='#2ecc71') - ax.plot(x, encapsulate_times, 's-', linewidth=2, markersize=8, label='Encapsulation', color='#3498db') - ax.plot(x, decapsulate_times, '^-', linewidth=2, markersize=8, label='Decapsulation', color='#e74c3c') - - ax.set_xticks(x) - ax.set_xticklabels([f'{v}\n({s})' for v, s in zip(variants, security_levels)]) - ax.set_xlabel('Kyber Variant (Security Level)', fontsize=12) - ax.set_ylabel('Time (ms)', fontsize=12) - ax.set_title('PyKyber Performance Scalability', fontsize=14, fontweight='bold') - ax.legend() - ax.grid(alpha=0.3) - - for i, variant in enumerate(variants): - for j, (data, label) in enumerate([(keypair_times, 'keypair'), - (encapsulate_times, 'encapsulate'), - (decapsulate_times, 'decapsulate')]): - ax.annotate(f'{data[i]:.2f}', - (i, data[i]), - textcoords="offset points", - xytext=(0, 10), - ha='center', fontsize=8) - - plt.tight_layout() - plt.savefig('performance-tests/scalability.png', dpi=150) - print("Saved: performance-tests/scalability.png") - plt.close() - - -def create_throughput_chart(results): - """Create a throughput chart showing operations per second.""" - variants = ['Kyber512', 'Kyber768', 'Kyber1024'] - - keypair_ops = [1000 / results[v]['keypair'] for v in variants] - encapsulate_ops = [1000 / results[v]['encapsulate'] for v in variants] - decapsulate_ops = [1000 / results[v]['decapsulate'] for v in variants] - - fig, ax = plt.subplots(figsize=(10, 6)) - - x = range(len(variants)) - width = 0.25 - - bars1 = ax.bar([i - width for i in x], keypair_ops, width, label='Keypair Generation', color='#2ecc71') - bars2 = ax.bar(x, encapsulate_ops, width, label='Encapsulation', color='#3498db') - bars3 = ax.bar([i + width for i in x], decapsulate_ops, width, label='Decapsulation', color='#e74c3c') - - ax.set_xlabel('Kyber Variant', fontsize=12) - ax.set_ylabel('Operations per Second', fontsize=12) - ax.set_title('PyKyber Throughput (Operations per Second)', fontsize=14, fontweight='bold') - ax.set_xticks(x) - ax.set_xticklabels(variants) - ax.legend() - ax.grid(axis='y', alpha=0.3) - - for bars in [bars1, bars2, bars3]: - for bar in bars: - height = bar.get_height() - ax.annotate(f'{int(height)}', - xy=(bar.get_x() + bar.get_width() / 2, height), - xytext=(0, 3), - textcoords="offset points", - ha='center', va='bottom', fontsize=8) - - plt.tight_layout() - plt.savefig('performance-tests/throughput.png', dpi=150) - print("Saved: performance-tests/throughput.png") - plt.close() - - -def print_summary(results): - """Print a summary table of results.""" - print("\n" + "=" * 60) - print("PyKyber Performance Summary (100 iterations each)") - print("=" * 60) - print(f"{'Variant':<12} {'Keypair (ms)':<15} {'Encapsulate (ms)':<18} {'Decapsulate (ms)':<18}") - print("-" * 60) - for variant in ['Kyber512', 'Kyber768', 'Kyber1024']: - r = results[variant] - print(f"{variant:<12} {r['keypair']:<15.3f} {r['encapsulate']:<18.3f} {r['decapsulate']:<18.3f}") - print("=" * 60) - - print("\nThroughput (operations/second):") - print("-" * 60) - print(f"{'Variant':<12} {'Keypair/s':<15} {'Encapsulate/s':<18} {'Decapsulate/s':<18}") - print("-" * 60) - for variant in ['Kyber512', 'Kyber768', 'Kyber1024']: - r = results[variant] - kp_ops = int(1000 / r['keypair']) - enc_ops = int(1000 / r['encapsulate']) - dec_ops = int(1000 / r['decapsulate']) - print(f"{variant:<12} {kp_ops:<15} {enc_ops:<18} {dec_ops:<18}") - print("=" * 60) - - -if __name__ == '__main__': - print("Running performance tests from python/tests/test_kyber.py...") - results = run_performance_tests() - print_summary(results) - create_comparison_chart(results) - create_operation_breakdown(results) - create_scalability_chart(results) - create_throughput_chart(results) - print("\nAll graphs saved in performance-tests/") +def machine_info(): + cpu = platform.processor() or platform.machine() + try: + for line in Path("/proc/cpuinfo").read_text().splitlines(): + if line.startswith("model name"): + cpu = line.split(":", 1)[1].strip() + break + except OSError: + pass + return { + "cpu": cpu, + "os": platform.platform(), + "python": platform.python_version(), + "iterations": ITERATIONS, + "rounds": ROUNDS, + } + + +def footnote(info): + return ( + f"{info['cpu']}, Python {info['python']}, release build. " + f"Median of {info['rounds']} rounds of {info['iterations']} calls." + ) + + +def style(ax): + ax.set_facecolor("white") + ax.grid(axis="y", color=GRID, linewidth=0.8) + ax.set_axisbelow(True) + for side in ("top", "right", "left"): + ax.spines[side].set_visible(False) + ax.spines["bottom"].set_color(GRID) + ax.tick_params(colors=MUTED, length=0) + + +def latency_chart(results, info): + """Time per call for the three ML-KEM sets, one bar per operation.""" + names = [f"ML_KEM_{level}" for level in LEVELS] + fig, ax = plt.subplots(figsize=(9, 5), facecolor="white") + style(ax) + width = 0.26 + gap = 0.02 + for j, op in enumerate(OPERATIONS): + xs = [i + (j - 1) * (width + gap) for i in range(len(names))] + medians = [results[n][op]["median_ms"] for n in names] + below = [results[n][op]["median_ms"] - results[n][op]["min_ms"] for n in names] + above = [results[n][op]["max_ms"] - results[n][op]["median_ms"] for n in names] + bars = ax.bar(xs, medians, width, color=OPERATION_COLORS[op], label=OPERATION_LABELS[op]) + ax.errorbar(xs, medians, yerr=[below, above], fmt="none", ecolor=INK, elinewidth=1, capsize=3) + for bar, value, top in zip(bars, medians, (results[n][op]["max_ms"] for n in names)): + # Label above the whisker, so the number never sits on the line. + ax.annotate(f"{value:.3f}", (bar.get_x() + bar.get_width() / 2, top), + xytext=(0, 4), textcoords="offset points", ha="center", + fontsize=8, color=INK) + ax.set_xticks(range(len(names))) + ax.set_xticklabels([f"ML-KEM-{level}" for level in LEVELS], color=INK, fontsize=11) + ax.set_ylabel("milliseconds per call (lower is faster)", color=MUTED) + ax.set_title("ML-KEM time per operation", loc="left", color=INK, fontsize=14, + fontweight="bold") + ax.legend(frameon=False, loc="upper left", labelcolor=INK) + fig.text(0.01, 0.01, footnote(info) + " Whiskers show the fastest and slowest round.", + fontsize=7, color=MUTED) + fig.tight_layout(rect=(0, 0.03, 1, 1)) + fig.savefig(HERE / "latency.png", dpi=DPI, facecolor="white") + plt.close(fig) + + +def comparison_chart(results, info): + """Operations per second, ML-KEM against the deprecated Kyber classes.""" + fig, axes = plt.subplots(1, 3, figsize=(12, 4.8), facecolor="white") + width = 0.36 + gap = 0.02 + for ax, op in zip(axes, OPERATIONS): + style(ax) + for j, (family, pattern) in enumerate(FAMILIES.items()): + xs = [i + (j - 0.5) * (width + gap) for i in range(len(LEVELS))] + rates = [MS_PER_S / results[pattern.format(level)][op]["median_ms"] for level in LEVELS] + is_kyber = family == "Kyber" + bars = ax.bar(xs, rates, width, color=OPERATION_COLORS[op], + alpha=KYBER_ALPHA if is_kyber else 1.0, + hatch="///" if is_kyber else "", edgecolor="white", linewidth=0) + for bar, value in zip(bars, rates): + ax.annotate(f"{value:,.0f}", (bar.get_x() + bar.get_width() / 2, bar.get_height()), + xytext=(0, 4), textcoords="offset points", ha="center", + fontsize=7.5, color=INK) + ax.set_xticks(range(len(LEVELS))) + ax.set_xticklabels(LEVELS, color=INK) + ax.set_title(OPERATION_LABELS[op], loc="left", color=INK, fontsize=12, fontweight="bold") + axes[0].set_ylabel("operations per second (higher is faster)", color=MUTED) + handles = [ + plt.Rectangle((0, 0), 1, 1, facecolor=MUTED), + plt.Rectangle((0, 0), 1, 1, facecolor=MUTED, alpha=KYBER_ALPHA, hatch="///", + edgecolor="white"), + ] + fig.legend(handles, ["ML-KEM (solid)", "Kyber, deprecated (hatched)"], frameon=False, + loc="upper right", labelcolor=INK, ncol=2) + fig.suptitle("ML-KEM and the legacy Kyber classes", x=0.01, ha="left", color=INK, + fontsize=14, fontweight="bold") + fig.text(0.01, 0.01, footnote(info) + " Axis labels are the parameter set (512, 768, 1024).", + fontsize=7, color=MUTED) + fig.tight_layout(rect=(0, 0.03, 1, 0.94)) + fig.savefig(HERE / "ml_kem_vs_kyber.png", dpi=DPI, facecolor="white") + plt.close(fig) + + +def markdown_table(results): + lines = [ + "| Class | Key generation | Encapsulation | Decapsulation |", + "|-------|----------------|---------------|---------------|", + ] + for family, pattern in FAMILIES.items(): + for level in LEVELS: + name = pattern.format(level) + cells = [] + for op in OPERATIONS: + ms = results[name][op]["median_ms"] + cells.append(f"{ms:.3f} ms ({MS_PER_S / ms:,.0f}/s)") + label = f"`ML_KEM_{level}`" if family == "ML-KEM" else f"`Kyber{level}`" + lines.append(f"| {label} | " + " | ".join(cells) + " |") + return "\n".join(lines) + + +def main(): + if not hasattr(pykyber, "ML_KEM_768"): + sys.exit("this pykyber build has no ML-KEM; build the 2.0 branch with maturin develop --release") + info = machine_info() + results = run() + (HERE / "results.json").write_text( + json.dumps({"machine": info, "results": results}, indent=2) + "\n", encoding="ascii" + ) + latency_chart(results, info) + comparison_chart(results, info) + print() + print(footnote(info)) + print() + print(markdown_table(results)) + print("\nWrote results.json, latency.png and ml_kem_vs_kyber.png in", HERE) + + +if __name__ == "__main__": + main() diff --git a/performance-tests/latency.png b/performance-tests/latency.png new file mode 100644 index 0000000..ee3199c Binary files /dev/null and b/performance-tests/latency.png differ diff --git a/performance-tests/ml_kem_vs_kyber.png b/performance-tests/ml_kem_vs_kyber.png new file mode 100644 index 0000000..6f38096 Binary files /dev/null and b/performance-tests/ml_kem_vs_kyber.png differ diff --git a/performance-tests/operation_breakdown.png b/performance-tests/operation_breakdown.png deleted file mode 100644 index 88644a7..0000000 Binary files a/performance-tests/operation_breakdown.png and /dev/null differ diff --git a/performance-tests/performance_comparison.png b/performance-tests/performance_comparison.png deleted file mode 100644 index b2a299a..0000000 Binary files a/performance-tests/performance_comparison.png and /dev/null differ diff --git a/performance-tests/requirements.txt b/performance-tests/requirements.txt new file mode 100644 index 0000000..58e0d3f --- /dev/null +++ b/performance-tests/requirements.txt @@ -0,0 +1,3 @@ +# Only needed to draw the benchmark charts (make benchmark). +# matplotlib 3.7 is the last line that supports Python 3.8. +matplotlib>=3.7 diff --git a/performance-tests/results.json b/performance-tests/results.json new file mode 100644 index 0000000..b81fd5f --- /dev/null +++ b/performance-tests/results.json @@ -0,0 +1,113 @@ +{ + "machine": { + "cpu": "Intel(R) Core(TM) i7-7700 CPU @ 3.60GHz", + "os": "Linux-7.0.0-42-generic-x86_64-with-glibc2.43", + "python": "3.14.4", + "iterations": 500, + "rounds": 7 + }, + "results": { + "ML_KEM_512": { + "keypair": { + "median_ms": 0.03084688, + "min_ms": 0.030227022, + "max_ms": 0.031714176 + }, + "encapsulate": { + "median_ms": 0.036884415999999996, + "min_ms": 0.034839664, + "max_ms": 0.037238023999999995 + }, + "decapsulate": { + "median_ms": 0.045211512, + "min_ms": 0.044502955999999996, + "max_ms": 0.046178892 + } + }, + "ML_KEM_768": { + "keypair": { + "median_ms": 0.052071804, + "min_ms": 0.051858684, + "max_ms": 0.05341931 + }, + "encapsulate": { + "median_ms": 0.05827729, + "min_ms": 0.057428306, + "max_ms": 0.060119014000000005 + }, + "decapsulate": { + "median_ms": 0.071233474, + "min_ms": 0.069818558, + "max_ms": 0.072420324 + } + }, + "ML_KEM_1024": { + "keypair": { + "median_ms": 0.08183407000000001, + "min_ms": 0.079728936, + "max_ms": 0.085947734 + }, + "encapsulate": { + "median_ms": 0.08870694, + "min_ms": 0.08488179, + "max_ms": 0.09071324400000001 + }, + "decapsulate": { + "median_ms": 0.105884678, + "min_ms": 0.10320048600000001, + "max_ms": 0.113365562 + } + }, + "Kyber512": { + "keypair": { + "median_ms": 0.03262543, + "min_ms": 0.031415108, + "max_ms": 0.034500692 + }, + "encapsulate": { + "median_ms": 0.03986382, + "min_ms": 0.039075866, + "max_ms": 0.04647873 + }, + "decapsulate": { + "median_ms": 0.041719248, + "min_ms": 0.040897228, + "max_ms": 0.04311243 + } + }, + "Kyber768": { + "keypair": { + "median_ms": 0.055750828, + "min_ms": 0.053028982, + "max_ms": 0.05862622 + }, + "encapsulate": { + "median_ms": 0.06226379, + "min_ms": 0.058065872000000004, + "max_ms": 0.074707204 + }, + "decapsulate": { + "median_ms": 0.063505224, + "min_ms": 0.061659314, + "max_ms": 0.066696846 + } + }, + "Kyber1024": { + "keypair": { + "median_ms": 0.08775699, + "min_ms": 0.083874044, + "max_ms": 0.095251412 + }, + "encapsulate": { + "median_ms": 0.095971828, + "min_ms": 0.094348506, + "max_ms": 0.10051261199999999 + }, + "decapsulate": { + "median_ms": 0.10352679799999999, + "min_ms": 0.095782972, + "max_ms": 0.108335928 + } + } + } +} diff --git a/performance-tests/scalability.png b/performance-tests/scalability.png deleted file mode 100644 index 890aa51..0000000 Binary files a/performance-tests/scalability.png and /dev/null differ diff --git a/performance-tests/throughput.png b/performance-tests/throughput.png deleted file mode 100644 index 1ec261f..0000000 Binary files a/performance-tests/throughput.png and /dev/null differ diff --git a/pyproject.toml b/pyproject.toml index fcd65a0..5377ca0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,8 @@ version = "2.0.0" description = "ML-KEM (FIPS 203) post-quantum key encapsulation in Rust, with legacy Kyber" readme = "README.md" requires-python = ">=3.8" -license = {text = "Unlicense"} +license = "MIT OR Apache-2.0" +license-files = ["LICENSE-MIT", "LICENSE-APACHE"] authors = [ {name = "Yoav Haimov", email = "haimovyoav@gmail.com"} ] @@ -21,7 +22,6 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Rust", - "License :: Public Domain", "Operating System :: OS Independent", "Topic :: Security :: Cryptography", ] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 4c1eabb..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -# No runtime dependencies - pure Rust extension diff --git a/scripts/check.sh b/scripts/check.sh index 311b737..7be5a74 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # The gate every commit must pass (AGENTS.md "Before saying you're done"). # Run from the repo root inside the virtualenv that holds requirements-dev.txt. -# CI runs this per commit via `git rebase --exec scripts/check.sh `. +# Check each commit locally with `git rebase --exec scripts/check.sh `. set -euo pipefail # External vectors (never committed). CI runs this script from the tip over