diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..f32d433 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,8 @@ +# `cargo test` links the test binaries, and with pyo3's extension-module +# feature on macOS the Python symbols are left to the interpreter at load +# time. maturin passes these flags itself for wheels; plain cargo does not. +[target.x86_64-apple-darwin] +rustflags = ["-C", "link-arg=-undefined", "-C", "link-arg=dynamic_lookup"] + +[target.aarch64-apple-darwin] +rustflags = ["-C", "link-arg=-undefined", "-C", "link-arg=dynamic_lookup"] diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..04c1120 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,5 @@ +# Set-up per issue #4: the Rust crypto core and the security report are +# reviewed by the project owner. Everything arrives already authored and +# must be second-checked (constant-time hand crypto, no third-party crypto). +/src/ @Zikithezikit +/SECURITY.md @Zikithezikit diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..fafcd7a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,27 @@ +--- +name: Bug report +about: Something in pykyber behaves differently from its documented or expected behavior +title: '' +labels: '' +assignees: '' +--- + +> **Security issue? Do not use this template.** [Private vulnerability reporting](https://github.com/Zikithezikit/pykyber/security/advisories/new) is the way to report anything that affects confidentiality/integrity, timing, or secret handling. Only file it publicly if you have already coordinated. + +**Environment (please fill in):** + +- pykyber version (`pip show pykyber`): +- Python version: +- Platform / architecture (e.g. Linux x86_64, Termux aarch64): + +**What happened?** + + + +```python +# repro code here +``` + +**What did you expect to happen?** + + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..30b7cbd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Report a security vulnerability + url: https://github.com/Zikithezikit/pykyber/security/advisories/new + about: >- + Security problems (timing side channels, secret handling, incorrect + crypto behavior) must not be filed as public issues. Use GitHub's + private vulnerability reporting (see SECURITY.md at the repo root). diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..c71d58f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,23 @@ +--- +name: Feature request +about: Suggest an addition to pykyber (crypto, API, packaging, docs) +title: '' +labels: '' +assignees: '' +--- + +> **Security issue? Do not use this template.** [Private vulnerability reporting](https://github.com/Zikithezikit/pykyber/security/advisories/new) is the way to report weaknesses. (An RFC-like proposal that happens to alter the crypto is fine here. A report of an exploitable flaw is not.) + +**Is your feature request related to a problem?** + + + +**Proposed solution** + + + +**Which of pykyber's promises does this touch?** + + + +**Anything else?** diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..223f8c0 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,34 @@ + + +## What does this PR do? + +## How was it tested? + + + +## Checklist (from AGENTS.md) + +- [ ] Spec sections cited in code for new crypto logic +- [ ] Legacy golden vectors pass unchanged +- [ ] No secret-dependent branches, divisions, or indexing (asm checked: yes/no/n.a.) +- [ ] Secret intermediates zeroized on all paths +- [ ] Inputs validated before secret-dependent work +- [ ] No new runtime deps; no unsafe; no panics on untrusted input +- [ ] Tests added (vectors for crypto); fmt/clippy/test/pytest green on every commit (`git rebase --exec`) +- [ ] Review fixes folded into the commits they fix (no "fix review" commits); rebased on current `dev-v2` +- [ ] CHANGELOG updated (if user-visible) +- [ ] Sources checked: , , diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index fc9a638..6d445d3 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -9,6 +9,11 @@ on: push: branches: - main + # Manual edit, WP-0 / issue #4: 2.0 development happens on dev-v2, so + # CI must also run on pushes there (PRs already run on pull_request). + # `maturin generate-ci github` will not emit this line - re-add it if + # the file is ever regenerated. + - dev-v2 tags: - '*' pull_request: @@ -18,6 +23,53 @@ permissions: contents: read jobs: + # Added by hand for issue #7 (maturin generate-ci does not emit it; re-add it + # if the file is regenerated). Making `test` and `commits` required checks on + # dev-v2 is a branch-protection setting that only the maintainer can change. + test: + name: test (${{ matrix.os }}, py${{ matrix.python }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python: ['3.8', '3.x'] + exclude: + # setup-python has no Python 3.8 build for Apple silicon. + - os: macos-latest + python: '3.8' + include: + - os: macos-15-intel + python: '3.8' + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python }} + - name: Install Rust toolchain + run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy --no-self-update + - name: Create virtualenv + shell: bash + run: | + python -m venv "$RUNNER_TEMP/venv" + if [ -d "$RUNNER_TEMP/venv/Scripts" ]; then BIN=Scripts; else BIN=bin; fi + 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: Fetch external test vectors + run: python scripts/fetch_vectors.py + - name: cargo fmt + run: cargo fmt --check + - name: cargo clippy + run: cargo clippy --all-targets -- -D warnings + - name: cargo test + run: cargo test --locked --no-fail-fast + - name: maturin develop + run: maturin develop + - name: pytest + run: python -m pytest python/tests + linux: runs-on: ${{ matrix.platform.runner }} strategy: @@ -295,7 +347,7 @@ jobs: name: Release runs-on: ubuntu-latest if: ${{ startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' }} - needs: [linux, linux-pypy, musllinux, windows, macos, android, sdist] + needs: [test, linux, linux-pypy, musllinux, windows, macos, android, sdist] permissions: # Use to sign the release artifacts id-token: write diff --git a/.gitignore b/.gitignore index afc1e8a..5981856 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ __pycache__/ # Opencode .opencode/ + +# External test vectors, fetched by scripts/fetch_vectors.py +tests/vectors/external/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1649c70 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,137 @@ +# 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 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), split into work packages (WPs), the sub-issues of #3. Read the WP you are assigned before you start. + +## Non-negotiables + +1. **Legacy Kyber output never changes.** `Kyber512/768/1024` must produce byte-identical results to v1.0.5. The golden vectors in `tests/vectors/legacy-kyber-v1.0.5.json` are append-only: never regenerate or edit them to make a test pass. If a change breaks them, the change is wrong. +2. **Follow the spec, not memory.** ML-KEM code implements FIPS 203 (final). Before writing or reviewing an algorithm, read its text in the standard and cite it in a comment (`// FIPS 203 Alg 18, step 9`). Round-3 Kyber and ML-KEM differ in ways that look like typos (`G(d‖k)`, no `H(m)`, no `H(c)` in the KDF); don't "fix" one into the other. +3. **Never weaken a test to make it pass.** Don't loosen an assertion, regenerate expected values from the code under test, or mark a test `#[ignore]`/`skip`. If you think a test is wrong, show evidence from a primary source and say so. +4. **No new runtime dependencies** without maintainer approval. Dev-only crates for testing (e.g. a reference ML-KEM for differential tests, `cargo-fuzz`) are fine in `[dev-dependencies]`. +5. **Don't touch the public API** beyond what the WP asks. No renames, no removals, no behavior changes to existing classes (other than the WP-6 deprecation warning). +6. **Don't claim what you didn't verify.** In PR descriptions and reports, separate "verified (how)" from "believed". If you couldn't run something, say so. + +## Constant-time and secret-handling rules (Rust) + +These apply to any value derived from a secret key, seed, `m`, `r`, noise, a decrypted message, or the implicit-rejection flag. + +- No `if`, `match`, `&&`/`||` short-circuit, `?`, early `return`, or loop bound that depends on secret data. +- No `/` or `%` on secret values; use the existing multiply-and-shift (Barrett/Montgomery) helpers. Division by public constants on public lengths is fine. +- No array indexing with secret-derived indices. +- No `==` on secret byte slices. Use the constant-time compare/select in `verify.rs` (or its ML-KEM successor), which must sit behind an optimization barrier. +- Mask idioms like `x.wrapping_neg() & y` are **not** guaranteed constant-time: LLVM has turned them into branches (CVE-2024-37880, "Clangover"). Route the bit through a barrier, and check the assembly when in doubt. +- Zeroize every secret intermediate before the function returns (`zeroize::Zeroizing`, or `.zeroize()` on every exit path). FIPS 203 §3.3 requires it, and the decaps reject flag must never be returned or stored. +- Validate inputs (lengths, the §7.2 modulus check, the §7.3 hash check) **before** any secret-dependent work, and return typed errors. +- Randomness for keys and encapsulation comes from the OS RNG; propagate failure as an error and never fall back to a weaker source. +- No `unsafe`. No `unwrap`/`expect`/`panic!`/indexing that can panic on attacker-controlled input outside tests. Panics must not cross the PyO3 boundary. +- No floating point anywhere in crypto code (FIPS 203 §3.3). +- `*_internal` functions that take caller-chosen randomness are test-only: `pub(crate)`, exposed only under `cfg(any(test, feature = "acvp"))`. + +## Python rules + +- Public classes mirror the existing `Kyber*` shape (instance = keypair, static `encapsulate`/`decapsulate`/`from_keys`, hex/b64/dict, tuple unpacking). Keep `__all__` and the `.pyi` stubs in sync. +- Validate sizes in `_validation.py` before calling Rust; raise `KyberError` (or a subclass), never a bare `Exception`. +- Kyber and ML-KEM keys have identical sizes. Never accept an object from one family in the other's methods. + +## Constants, not magic numbers + +Applies to all new code, Rust and Python, tests included. + +- Give every number a name unless its meaning is obvious on the line. `0`, `1`, `x / 2`, and a loop over the 8 bits of a byte are fine. A bit width, a size, a modulus, a shift, a multiplier, an offset, a count of rounds or inputs, or a residue you single out is not: it gets a `const` with a doc comment saying what it is and where it comes from (a FIPS 203 table or algorithm line, or the derivation, such as "ceil(2^31 / q)"). +- Derive instead of retyping. Build sizes from the constants that already exist (`KYBER_N`, `KYBER_Q`, `KYBER_POLYBYTES`, `KYBER_SYMBYTES`) and from type widths (`u8::BITS`, `i16::BITS - 1`), so a size such as 1568 or a shift of 15 is never written out. If the same literal appears twice, it needs a name. +- Prefer a loop or accumulator driven by a named width over hand-unrolled shifts with a different literal on every line. +- Exceptions: a function or test that transcribes a spec algorithm, formula or table line by line may keep the standard's own literals if a comment names the algorithm and lines. Expected values in test vectors, PRNG seeds, and the values in the parameter tables themselves (one match arm per set, commented with the table) are data, not magic numbers. +- Legacy code under `src/kyber/` mirrors the reference C and is frozen. Do not tidy its literals as a drive-by; that would be its own refactor, checked against the golden vectors. + +## Workflow + +- Branch from `dev-v2` as `wp-`, and open every PR against `dev-v2`. Never target `main` or `dev`: the maintainer merges `dev-v2` into `main` once, when 2.0 is done. +- One WP (or less) per PR; Conventional Commit messages (`feat:`, `fix:`, `test:`, `ci:`, `docs:`, `security:`, `refactor:`). Never push directly to `dev-v2`, `main` or `dev`, never merge, never force-push shared branches. +- **Every commit must pass on its own.** PRs are rebase-merged, so each commit lands on `dev-v2` as is. Each commit must build and pass the checks below, not just the last one in the PR. +- **Fix code in the commit that introduced it.** When review or CI finds a problem, don't add a "fix review comments" commit. Make a fixup commit and fold it in: + ```bash + git commit --fixup= + GIT_SEQUENCE_EDITOR=: git rebase -i --autosquash dev-v2 + git push --force-with-lease # only ever on your own wp-* branch + ``` + Rebase onto the latest `dev-v2` the same way instead of merging `dev-v2` into your branch. +- Keep refactors separate from behavior changes. Crypto PRs should stay under about 400 changed lines, not counting vector files. +- Match the surrounding code style. New ML-KEM code is generic over the parameter set; don't add more `_512/_768/_1024` copies. +- Add a line under `## Unreleased` in `CHANGELOG.md` for user-visible changes. Version numbers live in **both** `Cargo.toml` and `pyproject.toml`. + +### PR descriptions written by agents + +- The description goes to the maintainer and reviewers as prose, not as a template shape. Keep the facts (commit list, verification commands and outcomes, sources checked) and state them plainly; read the draft once as a reviewer would and rewrite anything that reads like generated text: bold decoration on every item, dashes chaining every clause, staged close-outs like "bottom line is", fact-free emphasis. +- Post only character-safe text. Write the body or comment to a file first and byte-check that it is 7-bit ASCII (`grep -nP '[^\x00-\x7F]' ` must report no matches) before creating or editing it; non-ASCII text has already reached GitHub as mojibake in this repo. Use ASCII hyphens and straight quotes; tidy the file until the check is clean. +- Fetch and update PR/issue text through the REST API when the `gh` commands themselves fail on GraphQL errors (here, `gh issue view` and `gh pr edit` die on the "Projects (classic)" deprecation): for example `gh api -X PATCH repos/Zikithezikit/pykyber/pulls/ -F body=@`, then read the body back with `gh api ... --jq .body` and run the ASCII check again. What GitHub stores is what reviewers see. + +### Before saying you're done + +Run all of these and report the results: + +```bash +cargo fmt --check +cargo clippy --all-targets -- -D warnings +cargo test --no-fail-fast +maturin develop && pytest python/tests +``` + +Then prove every commit in the branch passes, not only the tip: + +```bash +git rebase --exec 'cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test --no-fail-fast && maturin develop && pytest python/tests' dev-v2 +``` + +If you touched secret-handling code, also complete the PR checklist below and include evidence (e.g. the assembly grep output) in the PR. + +## Online sources + +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: + - 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. +5. Other implementations (Go `crypto/mlkem`, BoringSSL, RustCrypto `ml-kem`, libcrux) are for reading and for comparing outputs in dev-only tests. Never copy their code into this repo, not even with edits. Write ours from the FIPS 203 text. If you think copying is needed, stop and ask. + +## Security-review agents + +When asked to audit code (WP-8/WP-9), you are a reviewer, not an implementer: don't change code in the same task unless told to. For each issue, report: + +``` +ID: SEC- +Title: +Severity: Critical / High / Medium / Low / Info (with one-line rationale) +Location: path:line (function) +Class: timing | memory-hygiene | input-validation | rng | panic/DoS | api-misuse | spec-deviation | supply-chain +Evidence: what you observed (code, asm snippet, test output). Mark it "hypothesis" if not demonstrated. +Related: CVE / paper / spec section, with link +Legacy impact: does a fix change Kyber v1.0.5 output? (must be "no", or the fix needs another approach) +Suggested fix + test that would catch a regression +``` + +Check at least: everything in the constant-time rules above; KyberSlash (division by q) and Clangover (mask-to-branch) patterns in `poly`, `polyvec` and `verify`; implicit-rejection selection; input checks; zeroization on every exit path including errors; RNG source and error handling; reachability of panics from Python; and key/algorithm confusion in the Python API (cf. 2026 CVEs about binding a key to the wrong pair). Report "no findings" per area explicitly instead of omitting it. + +## PR checklist (copy into the PR description) + +``` +- [ ] Spec sections cited in code for new crypto logic +- [ ] Legacy golden vectors pass unchanged +- [ ] No secret-dependent branches, divisions, or indexing (asm checked: yes/no/n.a.) +- [ ] Secret intermediates zeroized on all paths +- [ ] Inputs validated before secret-dependent work +- [ ] No new runtime deps; no unsafe; no panics on untrusted input +- [ ] No magic numbers: sizes, widths, multipliers and counts are named, documented constants +- [ ] Tests added (vectors for crypto); fmt/clippy/test/pytest green on every commit (`git rebase --exec`) +- [ ] Review fixes folded into the commits they fix (no "fix review" commits); rebased on current `dev-v2` +- [ ] CHANGELOG updated (if user-visible) +- [ ] Sources checked: — — +``` diff --git a/CHANGELOG.md b/CHANGELOG.md index a44532b..540203d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## 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. + +## v2.0.0 + +Planned release; not tagged or published yet. Adds ML-KEM (FIPS 203) beside the legacy Kyber classes, which are deprecated. Kyber output is unchanged from 1.0.5. Kyber (round 3) and ML-KEM are not interoperable, so both sides of a connection must switch; see `docs/migration.md`. + +- 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 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`. + ## v1.0.5 - CI: add Android wheel builds (aarch64-linux-android, armv7-linux-androideabi, x86_64-linux-android × Python 3.8–3.14) — proper Termux support via PEP 738 android platform tags diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..67ac969 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,59 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +@AGENTS.md + +## 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). 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 work packages are the sub-issues of #3. + +## Commands + +```bash +maturin develop # build the Rust extension into .venv; required before Python tests +make dev-build # clean + maturin develop + regenerate python/pykyber/_pykyber.pyi (pyo3-stubgen) +make benchmark # .venv/bin/python performance-tests/benchmark.py + +cargo test --no-fail-fast # Rust tests (src unit tests + tests/*.rs) +cargo test --test api_kem # one Rust integration test file +cargo test # single Rust test by name +cargo clippy --all-targets -- -D warnings # clean since #25; keep it that way + +pytest python/tests # Python tests; `pip install -r requirements-dev.txt` for pytest + maturin +scripts/check.sh # the full gate: fmt, clippy -D warnings, cargo test, maturin develop, pytest +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); 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 + +Three layers (the legacy Kyber path; ML-KEM sits beside it, see below): + +1. **Rust core `src/kyber/`**: round-3 Kyber (`kem.rs` pre-hashes `m` and puts `H(c)` into the KDF, so it is not ML-KEM). `params.rs` holds constants per level. `kem.rs`/`indcpa.rs`/`polyvec.rs` are copied per level as `_512`, unsuffixed (= 768, `KYBER_K = 3`), and `_1024` functions. `api.rs` is a safe Rust API for 768 only. `verify.rs` has the constant-time compare/`cmov`; `error.rs` has `KyberError`; secrets use `zeroize`. +2. **PyO3 boundary `src/lib.rs`**: private `#[pyfunction]`s (`_keypair_{512,768,1024}`, `_encapsulate_*`, `_decapsulate_*`, plus unsuffixed 768 aliases) in module `pykyber._pykyber` (`module-name` in `pyproject.toml`; the Python source root is `python/`). Errors are raised as a generic `PyException`. Key generation uses `rand::thread_rng()`. +3. **Python wrapper `python/pykyber/`**: + - `_kyber.py` has the `Kyber512/768/1024` classes (an instance is a keypair; static `encapsulate`/`decapsulate`/`from_keys`). + - `_types.py` has `Keypair`/`EncapsulationResult` (tuple-unpackable, hex/b64/dict). + - `_validation.py` has decorators that size-check inputs before calling Rust. + - `__init__.py` wraps the `_pykyber` functions with those validators and sets `__all__`. + +ML-KEM (WP-3 to WP-5, merged): `src/mlkem/` is the FIPS 203 core, generic over `K` (`params.rs`, `kpke.rs`, `kem.rs`, `check.rs` for the section 7.2/7.3 checks, `ct.rs` for the constant-time reject select); it shares only audited primitives with `src/kyber/` (the list is in the module docs of `src/mlkem/mod.rs`). `src/pymlkem.rs` holds the private PyO3 functions `_mlkem_{512,768,1024}_{keygen,encaps,decaps}`; it calls only the public functions of `mlkem::kem`, never `kem::acvp` or the `acvp` feature, and raises the `KyberError` subclasses defined in `python/pykyber/_error.py` (looked up by name at raise time). `python/pykyber/_mlkem.py` has `ML_KEM_512/768/1024` (one base class, three thin subclasses); `MlKemKeypair` and `MlKemEncapsulationResult` in `_types.py` are deliberately not subclasses of the Kyber types, and `_validation.py` refuses Kyber objects in ML-KEM methods by type. Tests: `python/tests/test_mlkem*.py`; the ACVP files come from `scripts/fetch_vectors.py` and a missing file fails the run. + +Official vectors (WP-4, merged): the manifest pins ACVP, C2SP CCTV and Wycheproof files by commit and SHA-256 (`tests/vectors/manifest.json`); the Rust side is `src/mlkem/{acvp,cctv,wycheproof,differential}_tests.rs` and the Python side `python/tests/test_mlkem_{acvp,cctv,wycheproof}.py`. Two things to know. The CCTV `unluckysample` files and accumulated hashes were made with the pre-final `G(d)` (no `k` byte), so `cctv_tests.rs` builds those keys with `kpke::keygen_expanded` and the old derivation; the final one is covered by ACVP, and the CCTV tests are not evidence about `G(d || k)`. The differential test compares against the dev-only `ml-kem` 0.3.3 crate (needs rustc 1.87+); `flate2` is a dev-dependency for the gzip modulus files. `cargo test` takes about a minute because the 10 000-run CCTV hashes run in the dev profile; `PYKYBER_CCTV_RUNS=1000000 cargo test --release --lib accumulated` runs the million-run hashes (about 8 minutes). + +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. That log was migrated to the root `SECURITY.md`. + +## Repo state + +- GitHub `Zikithezikit/pykyber`. The default branch is still `dev`; `main` exists. **2.0 work happens on `dev-v2`**: branch from it and open PRs against it, never `main` or `dev`. The maintainer merges `dev-v2` into `main` once at the end, and `main` then becomes the default. +- Issues #4–#18 are the 2.0 work packages, all sub-issues of #3 in milestone `2.0.0`, with "blocked by" links set. +- Tags are `v0.1.0`…`v0.2.0` and `v1.0.5`. The legacy golden vectors should come from `v1.0.5` / the PyPI 1.0.5 wheel. +- Commit style is Conventional Commits (`fix:`, `feat:`, `security:`). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a3763f9 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,138 @@ +# Contributing to PyKyber + +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. The 2.0 work packages are the +sub-issues of #3. + +## Development setup + +Requirements: a Rust toolchain (stable), Python >= 3.8, and `maturin`. + +```bash +git clone https://github.com/Zikithezikit/pykyber +cd pykyber +python -m venv .venv +source .venv/bin/activate +pip install -r requirements-dev.txt +maturin develop # builds the Rust extension into .venv +pip install -e . +``` + +## Build & test commands + +```bash +maturin develop # rebuild the .so after any Rust change +pytest python/tests # Python tests (needs the built .so) +cargo fmt --check # formatting +cargo clippy --all-targets -- -D warnings # lints +cargo test --no-fail-fast # Rust unit + integration tests +make dev-build # clean + rebuild + regenerate python/pykyber/_pykyber.pyi +make benchmark # performance-tests/benchmark.py +``` + +These commands are the gate for every PR: + +```bash +scripts/check.sh # fmt, clippy -D warnings, cargo test, maturin develop, pytest +``` + +CI runs the same checks on Linux, macOS and Windows. + +## Branching and PRs + +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 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. +- Nobody merges into `main` or `dev` during 2.0; the maintainer merges + `dev-v2` into `main` once, when 2.0 is done. +- PRs are **rebase-merged** into `dev-v2`, so the reviewed commits land + unchanged. That is why every commit in a PR must build and pass the checks + on its own, not just the tip. + +## Commits + +- **Conventional Commits**: `feat:`, `fix:`, `test:`, `ci:`, `docs:`, + `security:`, `refactor:`. +- **Never** push directly to `dev-v2`, `main`, or `dev`; never force-push + shared branches. Force-pushing is only ever done to your own `wp-*` + branch (`git push --force-with-lease`). +- Keep refactors separate from behavior changes. +- **Fix code in the commit that introduced it.** When review or CI finds a + problem, don't add a "fix review comments" commit. Make a fixup commit and + fold it in: + + ```bash + git commit --fixup= + GIT_SEQUENCE_EDITOR=: git rebase -i --autosquash dev-v2 + git push --force-with-lease + ``` + +- Rebase your branch onto the latest `dev-v2` instead of merging `dev-v2` + into your branch (same recipe, without the fixups). +- To prove the *whole* branch passes, not only the tip: + + ```bash + git rebase --exec 'cargo fmt --check && cargo clippy --all-targets -- -D warnings \ + && cargo test --no-fail-fast' dev-v2 + ``` + +## Code rules (the short list) + +The full rules are in `AGENTS.md`; the ones most often violated: + +- **Legacy Kyber output never changes.** `Kyber512/768/1024` must produce + byte-identical results to v1.0.5. The golden vectors in + `tests/vectors/legacy-kyber-v1.0.5.json` (added in WP-1) are append-only: + never regenerate or edit them to make a test pass. +- **Follow the spec, not memory.** ML-KEM code implements FIPS 203 (final). + Cite the algorithm number in comments (`// FIPS 203 Alg 18, step 9`). + Round-3 Kyber and ML-KEM differ in details that look like typos; don't + "fix" one into the other. +- **Never weaken a test to make it pass.** No loosened assertions, no + regenerating expected values from the code under test, no `#[ignore]`/skip. +- **No new runtime dependencies.** Dev-only test crates are fine. +- **Constant-time and secret-handling rules** apply to anything derived from + key material, seeds, randomness, or decryption results: no secret-dependent + branches, no `/`/`%` on secrets, no secret-indexed lookups, no `==` on + secret slices. Zeroize secret intermediates on every exit path. Validate + inputs before any secret-dependent work. +- No `unsafe`. No `unwrap`/`expect`/panics on attacker-controlled input + outside tests. Panics must not cross the PyO3 boundary. +- Python-side inputs are validated in `python/pykyber/_validation.py` before + calling Rust; errors are `KyberError` (or a subclass), never bare + `Exception`. + +## Test vectors + +- Golden vectors generated by this project live in `tests/vectors/` and are + append-only once introduced. +- External vectors (NIST ACVP, C2SP CCTV, Wycheproof) are **never committed**. + They are fetched at test time from a pinned commit and verified against + SHA-256 hashes in `tests/vectors/manifest.json`. +- Reference implementations (Go `crypto/mlkem`, RustCrypto, libcrux, ...) are + 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 +vulnerability reporting (Security tab, "Report a vulnerability"); the +details are in [SECURITY.md](SECURITY.md). + +AI agents get the same review as human PRs and follow the same rules; agents +never merge. diff --git a/Cargo.lock b/Cargo.lock index 9f5ef5a..721c1b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,18 +2,109 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "ctutils" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2376ffd6f96d8a9db59875b4b6b0237d6900b5baf64279714e05493ce64828a" +dependencies = [ + "cmov", +] + +[[package]] +name = "der" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a996a108feb8b427a6e12fcf6af4aa83409b79d72c15337d7741368131ee004" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "crypto-common", +] + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -31,6 +122,16 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "ctutils", + "typenum", +] + [[package]] name = "indoc" version = "2.0.7" @@ -40,12 +141,44 @@ dependencies = [ "rustversion", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "keccak" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" +dependencies = [ + "cfg-if", + "cpufeatures", +] + +[[package]] +name = "kem" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd" +dependencies = [ + "crypto-common", + "rand_core 0.10.1", +] + [[package]] name = "libc" version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + [[package]] name = "memoffset" version = "0.9.1" @@ -55,12 +188,67 @@ dependencies = [ "autocfg", ] +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "ml-kem" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9ba01ebcf90f6aff8e9b9f55bb69b19e6250e4ade5787d0040eaa27dd226238" +dependencies = [ + "hybrid-array", + "kem", + "module-lattice", + "pkcs8", + "rand_core 0.10.1", + "sha3", + "shake", +] + +[[package]] +name = "module-lattice" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" +dependencies = [ + "ctutils", + "hybrid-array", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -87,11 +275,15 @@ dependencies = [ [[package]] name = "pykyber" -version = "0.1.7" +version = "2.0.0" dependencies = [ + "flate2", + "ml-kem", "pyo3", "rand", - "rand_core", + "rand_core 0.6.4", + "serde", + "serde_json", "zeroize", ] @@ -140,7 +332,7 @@ dependencies = [ "proc-macro2", "pyo3-macros-backend", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -153,7 +345,7 @@ dependencies = [ "proc-macro2", "pyo3-build-config", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -167,13 +359,13 @@ dependencies = [ [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -183,7 +375,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -195,12 +387,105 @@ dependencies = [ "getrandom", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha3" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" +dependencies = [ + "digest", + "keccak", + "sponge-cursor", +] + +[[package]] +name = "shake" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" +dependencies = [ + "digest", + "keccak", + "sponge-cursor", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "spki" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + [[package]] name = "syn" version = "2.0.117" @@ -212,12 +497,29 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "target-lexicon" version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -253,7 +555,7 @@ checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -273,5 +575,17 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 6f88410..9f58e78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,8 @@ [package] name = "pykyber" -version = "1.0.5" +version = "2.0.0" edition = "2021" +license = "MIT OR Apache-2.0" [lib] name = "pykyber" @@ -13,8 +14,19 @@ zeroize = { version = "1.7", features = ["derive"] } rand = "0.8" rand_core = "0.6" +[features] +# Exposes the ML-KEM `*_internal` functions that take caller-chosen randomness, +# for vector tests only (FIPS 203 section 3.3). Never enable it in a release. +acvp = [] + [dev-dependencies] +flate2 = "1" +# Independent FIPS 203 implementation for the differential test only (never a +# runtime dependency). `hazmat` exposes encapsulation with a caller-chosen m. +ml-kem = { version = "=0.3.3", features = ["hazmat"] } rand = "0.8" +serde = { version = "1", features = ["derive"] } +serde_json = "1" [profile.dev] opt-level = 0 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/README.md b/README.md index 1158e5f..bab61d4 100644 --- a/README.md +++ b/README.md @@ -1,147 +1,52 @@ # PyKyber -A Python library for Kyber post-quantum key encapsulation, implemented in Rust. +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. -## Installation +## Install ```bash pip install pykyber ``` -## Quick Start +Python 3.8 or newer. Prebuilt wheels cover Linux (glibc and musl), macOS and Windows. -```python -import pykyber - -# Generate a keypair (Alice) -alice_keypair = pykyber.Kyber768() - -# Encapsulate - create shared secret (Bob) -bob_result = pykyber.Kyber768.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}") -``` - -## API Usage +## Quick start ```python import pykyber -# Create a keypair - instant generation on class instantiation -keypair = pykyber.Kyber512() # ~AES-128 security -keypair = pykyber.Kyber768() # ~AES-192 security -keypair = pykyber.Kyber1024() # ~AES-256 security - -# Deterministic key generation from a 64-byte seed -seed = bytes(range(64)) -keypair = pykyber.Kyber768(seed=seed) - -# 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.Kyber768.encapsulate(public_key) - -# Decapsulate with just ciphertext and secret key -shared_secret = pykyber.Kyber768.decapsulate(ciphertext, secret_key) - -# Load a Keypair object from existing keys -keypair = pykyber.Kyber768.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 | -|-----------|------------|------------|------------|---------------| -| Kyber-512 | 800 bytes | 1632 bytes | 768 bytes | 32 bytes | -| Kyber-768 | 1184 bytes | 2400 bytes | 1088 bytes | 32 bytes | -| Kyber-1024| 1568 bytes | 3168 bytes | 1568 bytes | 32 bytes | - - -## Error Handling - -All operations raise `KyberError` when invalid input is provided: - -```python -import pykyber - -try: - pykyber.Kyber768.encapsulate(b"too_short") -except pykyber.KyberError as e: - print(e) -# Output: Invalid public key for Kyber768.encapsulate: expected 1184 bytes, got 10. -# Ensure you're using the correct Kyber variant (Kyber512=800, Kyber768=1184, Kyber1024=1568). -``` +`ML_KEM_512`, `ML_KEM_768` (NIST's default) and `ML_KEM_1024` work the same way. -Common error cases: -- **Invalid public key size**: Wrong number of bytes for the Kyber variant -- **Invalid ciphertext size**: Wrong number of bytes when decapsulating -- **Invalid secret key size**: Wrong number of bytes for the Kyber variant -- **Invalid seed size**: Seed must be exactly 64 bytes for deterministic generation +## 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 (100 iterations each): - -| 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) | - -Run benchmarks and generate graphs: - -```bash -make benchmark -``` - -Generated graphs: - -### Performance Comparison -![Performance Comparison](performance-tests/performance_comparison.png) +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. -### Operation Breakdown -![Operation Breakdown](performance-tests/operation_breakdown.png) +| 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 | -### Scalability -![Scalability](performance-tests/scalability.png) +![ML-KEM time per operation](performance-tests/latency.png) -### Throughput -![Throughput](performance-tests/throughput.png) +## More +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. -## 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 new file mode 100644 index 0000000..32e1890 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,312 @@ +# Security Policy + +pykyber implements cryptographically-sensitive code, and its users run it to +protect secrets. Please treat potential weakness in it accordingly. + +## Reporting a vulnerability + +**Do not open a public issue for a security problem.** Use GitHub's private +vulnerability reporting, which is enabled on this repository: + +> + +(reachable from the repo's Security tab, "Report a vulnerability"). + +What helps us triage fastest: + +- A clear description of the weakness and your assessment of impact. +- Affected version(s) and/or commit, and which parameter set is involved + (`Kyber512/768/1024`, from 2.0 on also `ML_KEM_*`). +- A minimal reproduction (script or test patch), passing random keys. + Please **do not paste real key material, seeds, or shared secrets** into + the report. +- For timing findings: how you demonstrated or estimated the leak (code + path, compiler/optimizer version, measurement setting). +- A suggested fix, if you have one. + +## Response and disclosure + +- 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 + advisory once a fix ships. +- We will handle timing-attack claims by the standard in this repo: suspect + secret-dependent behavior must be backed by generated assembly or a + dynamic measurement, not just code reading. + +## Scope + +In scope: the published `pykyber` package and everything under `src/`: +timing side channels, secret zeroization and memory hygiene, input +validation, randomness source and failure handling, panics reachable from +Python, cryptographic correctness against the reference documents (round-3 +CRYSTALS-Kyber for the `Kyber*` classes, FIPS 203 for the `ML_KEM_*` +classes), and wrong-key-family/parameter confusion in the Python API. + +Out of scope: issues only exploitable by the party that already controls +the key or the machine; availability of the benchmark harness in +`performance-tests/`; breakages of third-party libraries that merely import +pykyber; social engineering. + +## Findings log + +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 tracked issue-by-issue. + +The log below was migrated on 2026-10-29 from +`.opencode/SECURITY.md`, a maintainer/agent working log that was +gitignored and therefore invisible to other contributors. The item content +is unchanged; each item is fixed in the current release. + +# Security Audit Notes (migrated log) + +This log tracks identified security concerns, cryptographic vulnerabilities, +and NIST compliance issues within the `pykyber` implementation. + +## 1. Timing Side-Channel Vulnerabilities [RESOLVED] + +### Non-Constant Time Arithmetic +The implementation used the standard division operator (`/`) on sensitive data in several performance-critical functions. +- **Affected Functions:** `poly_compress`, `poly_tomsg`, `polyvec_compress`, etc. +- **Resolution:** Replaced all divisions with constant-time modular arithmetic using high-precision reciprocals and bit-shifts. +- **Status:** FIXED. + +## 2. Weakened Security (Kyber-512) [RESOLVED] + +### Incorrect Noise Distribution +The implementation hardcoded the noise parameter `KYBER_ETA1` to `2` for all security levels. +- **Affected Constants:** `KYBER_512_K`, `KYBER_ETA1` in `src/kyber/params.rs`. +- **Resolution:** Updated `ETA1` to 3 for Kyber-512 and implemented `cbd3` noise generation. +- **Status:** FIXED. + +## 3. NIST Non-Compliance (Kyber-1024) [RESOLVED] + +### Incorrect Compression Parameters +The implementation used incorrect compression parameters for Kyber-1024 ($d_u=10, d_v=4$ instead of $11, 5$). +- **Resolution:** Corrected compression parameters and updated byte size constants. Implemented variant-specific compression/decompression logic for Kyber-1024. +- **Status:** FIXED. + +## 4. Memory Safety [RESOLVED] + +### Secret Material Persistence +Sensitive data could persist in memory due to Python's garbage collection and Rust's default drop behavior. +- **Resolution:** Integrated the `zeroize` crate. Implemented automatic zeroing on drop for `Poly`, `Polyvec`, `Keypair`, and `KeccakState` structs. Manually zeroed temporary local buffers in cryptographic functions. +- **Status:** FIXED. + +## 5. Logic Errors in Keccak Implementation [RESOLVED] + +### Buffer Overflow in Squeeze +The `keccak_squeeze` function used `store_u64` blindly, which could write past the end of the output buffer if the requested length was not a multiple of 8. +- **Resolution:** Refactored `keccak_squeeze` to handle arbitrary lengths safely while maintaining data-independent timing. +- **Status:** FIXED. + +## 6. Integer Overflow Hardening [RESOLVED] + +### NTT Butterfly Bounds +The 7-layer Number Theoretic Transform (NTT) used `i16` intermediate values. While mathematically bounded, it was close to the limit. +- **Resolution:** Hardened NTT butterfly logic using explicit `i32` arithmetic and updated `barrett_reduce` to handle a wider input range. +- **Status:** FIXED. + +# Legacy Kyber security pass (issue #18, WP-9) + +Audit and fixes for the round-3 `Kyber*` code, done on 2026-10-30 on branch +`wp9-legacy-security`. The result is evidence, not a proof, and this was done +by the project's own tooling, not by a third party. + +Method and limits. Assembly was read from release builds (`opt-level = 3`, +one codegen unit) of rustc 1.95.0 with `llvm-objdump`, for x86_64 and aarch64 +(and i686 and armv7 for the division scan). One compiler version, one flag set +and a disassembly show what that compiler did; they do not show that another +compiler, flag set or CPU will do the same, and nothing in this repo guarantees +constant time. The barriers below (`core::hint::black_box`) are best effort. +There is no dynamic timing test for the legacy code (that is WP-8, not merged). +Memory hygiene is limited to what safe Rust can do: moves and register spills +leave copies that cannot be wiped without `unsafe`, and every secret that +crosses into Python becomes a Python `bytes` object that cannot be wiped. + +No finding is High or above. + +## 7. `poly_frommsg` mask (SEC-001, Low, hardened) + +`poly_frommsg` builds `-(bit)` from a message bit, the pattern behind +CVE-2024-37880 ("Clangover"). In the release builds checked here (rustc 1.95, +x86_64 and aarch64) it was vectorized with no branch on message bits, so the +leak was not observed. The bit and the mask now go through `black_box`. +Disassembly after the change: the only branches are the bounds check and the +loop counter. Output is unchanged. Status: HARDENED. + +## 8. `verify` and `cmov` (SEC-002, Info, hardened) + +Before the change the compare ended in `setg` and the select was a vector +`and/andn/or`, with no branch on the result. The accumulated difference, the +flag and the mask now go through `black_box`, as `mlkem::ct` does. Disassembly +after the change shows `setne`/`cset` and a vector select; in `crypto_kem_dec` +the flag is stored, reloaded and passed to the select with no branch on it. +Status: HARDENED. + +## 9. Randomness source (SEC-003, Low, fixed) + +Legacy key generation and encapsulation used `rand::thread_rng()`, and the KEM +functions called `fill_bytes`, which panics when the generator fails. They now +use `OsRng` and `try_fill_bytes`, return `KyberError::RandomBytesGeneration`, +and Python raises `RandomnessError`. No fallback source exists. The half-built +secret key is wiped if the second draw fails. Checked by Rust tests with a +generator that fails, and end to end with `strace -e inject=getrandom:error=EIO` +(all six Python entry points raised `RandomnessError`, no panic). Status: FIXED. + +## 10. Decapsulation intermediates (SEC-004, Low, fixed) + +`m'`, the KDF input and the re-encrypted ciphertext were wiped only on the +normal exit path, the reject flag was never wiped, and the PRF output buffers in +`poly_getnoise_*` (the secret noise: the key in key generation, `r` in +encryption) were never wiped. They are now `Zeroizing`, so they are wiped on +unwinding too. The release disassembly shows the flag and buffer wipes on both +the normal and the cleanup path. There is no runtime test of the wipe (it needs +`unsafe` or an allocator hook); the implicit-rejection output is covered by a +new test. Status: FIXED, with the limits stated above. + +## 11. `from_keys` binding (SEC-005, Low, fixed, behavior change) + +`Kyber512/768/1024.from_keys` accepted any public key and secret key of the +right sizes, so a pair from two key pairs decapsulated to a different secret +with no error (the class of bug in CVE-2026-115408 for another library). It now +raises `KyberError` when the public key is not the copy in the secret key +(`sk = sk_pke || pk || H(pk) || z`). `H(pk)` and `z` are not checked. +`ML_KEM_*.from_keys` has the same gap and is not changed here. Status: FIXED +for `Kyber*`. + +## 12. Secrets returned to Python (SEC-006, Low, fixed) + +The bindings returned secret keys and shared secrets as `Vec`, freed with +the secret still in the allocation after PyO3 copied it into `bytes`. They are +now wiped after the copy. Status: FIXED. The Python `bytes` objects remain. + +## 13. Length checks in the Rust API (SEC-007) and `DummyRng` (SEC-008), Info, fixed + +`crypto_kem_keypair*` and `crypto_kem_enc*` indexed caller buffers and seeds +with no length check and panicked on a short one. The Python bindings size +every buffer, so no Python path reached this. They now return `InvalidInput`. +`DummyRng` (the stand-in on the seeded path, never asked for bytes) returned a +panic from `try_fill_bytes`; it now returns an error. `api::public` still +slices its argument and would panic on a short `sk`; no Python path calls it. +Status: FIXED, one Rust-only note left. + +## 14. Compress_11 differs from FIPS 203 (SEC-009, Info, not fixed) + +`polyvec_compress_1024` (`du = 11`) gives a result one too high at x = 1875 and +3125 compared with exact rounding (found in #28, pinned by +`legacy_compress_11_deviates_from_spec_at_two_residues`). It uses the d = 4/5/10 +multiplier 10321345 >> 35, which is not exact for d = 11. + +A fix was written and tested on 2026-10-31 and then reverted by the maintainer's +decision, to keep Kyber-1024 output compatible with v1.0.5. The replacement was +`(((x << 11) + 1664) * 645084) >> 31`, which matches exact rounding at every +residue (checked over all of Z_q). Its cost was: golden vector #6 and #7 +(Kyber-1024) in `tests/vectors/legacy-kyber-v1.0.5.json` changed one or more +ciphertext bytes, and by estimate about 46% of Kyber-1024 ciphertexts +(1 - (1 - 2/3329)^1024, assuming uniform coefficients; not measured) contain a +coefficient at one of the two residues, so a 1.x ciphertext would decapsulate to +the implicit-rejection secret across the change and the reverse. + +What stays correct: ML-KEM-1024 does not use this routine. `kpke` calls +`compress::poly_compress` with d = 11 and the exact multiplier 645084 >> 31, +which is tested against FIPS 203 (4.7) for every residue and negative +representative, by the ACVP and Wycheproof vectors, and by the differential test +against the `ml-kem` crate. Both ends of one legacy version use the same routine, +so Kyber-1024 round trips work; only interoperability with other round-3 +implementations (which use exact rounding) is affected at those two residues. +Status: NOT FIXED, by decision. Anyone who needs exact Compress_11 should use +`ML_KEM_1024`. + +## 15. Keccak stack spills (SEC-010, Low, not fixed) + +What the issue is. `keccakf1600_statepermute` runs the 24 rounds with the state +in registers and stack temporaries. Zeroizing the 25-word state array after the +hash (`s.zeroize()` in `fips202.rs`) clears the array, not the temporaries. The +temporaries depend on the hashed input: `m`, `z`, the PRF seed and the +decapsulation intermediates all pass through SHA3 or SHAKE. A reader of freed +stack memory (a core dump, a swapped page, a memory-disclosure bug, cold boot) +could find parts of them after the call returns. + +What was measured. Release build of this branch, rustc 1.95.0, `llvm-objdump` +on x86_64 (2026-10-31): one copy of the permutation, six callee-saved pushes, a +`subq $0x78, %rsp` frame and 22 `mov` stores to `(%rsp)`. The #28 figures +(0xf8-byte frame, about 70 spill stores) did not reproduce on this build, so the +size of the leak is not stable; its existence is. aarch64, i686 and armv7 were +not looked at. + +Why it is not fixed. +- Safe Rust cannot reach it. The temporaries exist only inside a compiler-chosen + frame that is dead after `ret`; clearing them needs a volatile write to + addresses the compiler picked (`unsafe`, forbidden here) or hand-written + assembly (also `unsafe`). +- The safe workaround, a stack-scrub call that writes a large zeroed local + through `black_box` after each hash, is a guess about where the old frame was. + Frame size and layout change with compiler version, target and inlining (the + 0x78 against 0xf8 above is an example), so the scrub would be too small on + some builds and hide the problem with no way to test it. It also costs a + memset per hash. I judged a mitigation that cannot be verified worse than a + documented limit. +- It is not the only copy. Registers spilled by callers, moves of secret arrays, + and the Python `bytes` objects holding keys and shared secrets all leave + copies that cannot be wiped either (see "Method and limits" above), so fixing this one would not + make the process memory clean. +- The permutation is shared with ML-KEM (`src/mlkem/mod.rs`); any change to it + belongs in its own reviewed change with new assembly evidence, not in a + security log commit. + +What would close it. A permutation written to keep the state in a fixed +`Zeroizing` buffer end to end (no temporaries the compiler can spill, which +cannot be guaranteed in safe Rust), or an `unsafe` stack scrub gated on a +maintainer decision to allow `unsafe` in one audited function. +Status: NOT FIXED, documented limit. + +## 16. Dependency advisories (SEC-011, SEC-012, Info) + +Queried OSV (which carries RustSec) on 2026-10-30 for the locked runtime +dependencies; `cargo audit` is not installed here, so this is not its output. +`rand` 0.8.5 has RUSTSEC-2026-0097 (needs the `log` feature, which is off): +`Cargo.lock` moved to 0.8.6. `pyo3` 0.27.2 has RUSTSEC-2026-0176 (`nth` on list +and tuple iterators) and RUSTSEC-2026-0177 (`PyCFunction::new_closure`), both +fixed only in 0.29.0; this code uses neither API, and the 0.29 upgrade is left +to the maintainer. `zeroize`, `rand_core`, `getrandom`, `rand_chacha`: none. + +## Checked, no findings + +- Panics reachable from Python: none. About 1000 inputs (random, all-0xFF and + coefficient-edge public keys, secret keys and ciphertexts, and wrong sizes for + every argument and seed) went through the `_pykyber` functions in a build with + overflow checks on; every result was a success or a plain exception. +- Integer overflow: sizes are compile-time constants or `len()` of a checked + slice; the fuzz above had overflow checks on. Not a proof for adversarial + coefficients beyond those tried. +- Length checks before secret work: `crypto_kem_dec*` and every binding check + `ct`, `sk`, `pk` and the seed before touching secrets. +- Ciphertext compare covers the full ciphertext (see CVE-2026-6330 below). +- KyberSlash scan: see the table. + +## CVE and paper review + +Dates are 2026-10-30. "Primary" means the issuing page or the paper itself. + +| Item | Status | Evidence | +|---|---|---| +| KyberSlash 1 (division in `poly_tomsg`) and 2 (`poly_compress`, `polyvec_compress`), (primary, read) | Not affected | The kyberslash.cr.yp.to page could not be fetched (transport error); the paper and search results agree the bug is a division by q on secret data. The code uses multiply-and-shift (`* 10321345 >> 35`). Whole-library release disassembly has no `div`/`idiv`/`udiv`/`sdiv` and no `__aeabi_*div`/`__udiv*` relocation on x86_64, aarch64, i686 and armv7; a control function with a runtime divisor does show them. This covers all three levels, compress, decompress, `poly_tomsg` and the ML-KEM code. The constant divisions became multiplies; their timing on cores with a data-dependent multiplier was not assessed. | +| CVE-2024-14041 (KyberSlash in Bouncy Castle Java) | Not affected | Other library; found in search (mondoo), not on the primary page. | +| CVE-2024-37880 (Clangover, `poly_frommsg`) | Mitigated | Not observed with rustc 1.95 on x86_64 and aarch64; barrier added (item 7). Record read via mirrors (Debian tracker, OSV, INCIBE); the NVD page itself did not render. The CVE names LLVM Clang through 18; Rust also uses LLVM, so the class applies. | +| CVE-2026-115408 (crypto11 ML-KEM key pair lookup) | Not affected, class addressed | Another library (Go). Read on OpenCVE (NVD status "Received"); not yet on NVD proper. Same class as `from_keys`: fixed for `Kyber*` (item 11), still open for `ML_KEM_*.from_keys`. | +| CVE-2026-6330 (wolfSSL ML-KEM ARM64 NEON compares half the ciphertext) | Not affected | CVE record read via the MITRE API listing and Debian tracker, not NVD. Our compare covers the whole ciphertext; the rejection test damages the first, middle and last bytes at all three levels. An AVX2 variant that compares 1536 of 1568 bytes appears in one vendor-blog result, unconfirmed. | +| 2026 SEALSQ and LightSec work on ML-KEM key generation and encoding | Unconfirmed, out of scope | Press release only (thequantuminsider, 2026-10-14); the paper was not read. It concerns hardware leakage through profiling plus lattice reduction; power and EM attacks are out of scope for this project. | +| Airtight side-channel testing framework (AFIT, ) | Not assessed | The AFIT page was not found by search; a paper with that title exists at ECCWS (academic-conferences.org). The framework was not run; dynamic timing tests are WP-8, which is not merged. | +| Reference implementations (Go, BoringSSL, RustCrypto, libcrux) | Not assessed | Comparison only; no code was copied. | + +## Benchmark (release wheels, base `8e56f40` vs this branch) + +`python/tests/test_kyber.py -k performance`, 100 iterations per op, median of +three alternating runs, one machine, noisy: decapsulation about the same at all +levels (+0.9%, 0.0%, -4.2%); key generation and encapsulation slower by up to +about 0.02 ms (Kyber768 keygen 0.051 to 0.074 ms, Kyber1024 keygen 0.087 to +0.113 ms, encapsulation +7% to +13%), most likely the `getrandom` call that +replaced `thread_rng`. The full key exchange moved -14% to +2%, inside the noise. diff --git a/docs/api.md b/docs/api.md index 5a8a1cb..2806728 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,73 +1,78 @@ # pykyber API Reference -## Classes +## ML-KEM classes -### `Kyber512`, `Kyber768`, `Kyber1024` +### `ML_KEM_512`, `ML_KEM_768`, `ML_KEM_1024` -Each variant has the same interface with variant-specific key sizes. +ML-KEM as specified in FIPS 203. Each parameter set has the same interface with its own key sizes. `ML_KEM_768` is the set NIST recommends as the default (FIPS 203, section 8). #### Class constants -| Constant | Kyber512 | Kyber768 | Kyber1024 | -|----------|----------|----------|-----------| +| Constant | ML_KEM_512 | ML_KEM_768 | ML_KEM_1024 | +|----------|------------|------------|-------------| | `PUBLIC_KEY_SIZE` | 800 | 1184 | 1568 | | `SECRET_KEY_SIZE` | 1632 | 2400 | 3168 | | `CIPHERTEXT_SIZE` | 768 | 1088 | 1568 | | `SHARED_SECRET_SIZE` | 32 | 32 | 32 | +| `SEED_SIZE` | 64 | 64 | 64 | -#### `__new__(seed: Optional[bytes] = None) -> Keypair` +`ALGORITHM` holds the name (`"ML-KEM-768"`). -Generate a new keypair. If `seed` is provided, it must be exactly 64 bytes for deterministic key generation. +#### `__new__(seed: Optional[bytes] = None) -> MlKemKeypair` + +Generate a new keypair. Random keys come from the operating system's random generator. If `seed` is provided, it must be exactly 64 bytes, `d || z` in the notation of FIPS 203 (Algorithm 16), and the same seed always gives the same keys. The seed is as sensitive as the secret key. ```python -keypair = pykyber.Kyber768() -keypair = pykyber.Kyber768(seed=bytes(range(64))) +keypair = pykyber.ML_KEM_768() +keypair = pykyber.ML_KEM_768(seed=bytes(range(64))) ``` -#### `encapsulate(public_key: bytes) -> EncapsulationResult` *(static)* +#### `encapsulate(public_key: bytes) -> MlKemEncapsulationResult` *(static)* -Encapsulate a shared secret using a public key without creating a keypair instance. +Encapsulate a shared secret using a public key without creating a keypair instance. The public key must pass the modulus check of FIPS 203 section 7.2. ```python -result = pykyber.Kyber768.encapsulate(public_key) +result = pykyber.ML_KEM_768.encapsulate(public_key) ``` #### `decapsulate(ciphertext: bytes, secret_key: bytes) -> bytes` *(static)* -Decapsulate a shared secret using ciphertext and secret key without a keypair instance. +Decapsulate a shared secret using ciphertext and secret key without a keypair instance. The secret key must pass the hash check of FIPS 203 section 7.3. A well-formed ciphertext that was not made for this key does not raise an error: the result is a different, pseudorandom secret (implicit rejection). ```python -ss = pykyber.Kyber768.decapsulate(ciphertext, secret_key) +ss = pykyber.ML_KEM_768.decapsulate(ciphertext, secret_key) ``` -#### `from_keys(public_key: bytes, secret_key: bytes) -> Keypair` *(classmethod)* +#### `from_keys(public_key: bytes, secret_key: bytes) -> MlKemKeypair` *(classmethod)* -Create a `Keypair` from existing key bytes. +Create a keypair object from existing key bytes. Only the sizes are checked here; the section 7.2 and 7.3 checks run when the keys are used. ```python -keypair = pykyber.Kyber768.from_keys(pk, sk) +keypair = pykyber.ML_KEM_768.from_keys(pk, sk) ``` --- -### `Keypair` +### `MlKemKeypair` -Returned by `Kyber*()` instantiation and `from_keys()`. +Returned by `ML_KEM_*()` instantiation and `from_keys()`. #### Properties -- `public_key` — raw public key bytes -- `secret_key` — raw secret key bytes -- `public_key_hex` — hex string -- `secret_key_hex` — hex string -- `public_key_b64` — base64 string -- `secret_key_b64` — base64 string +- `algorithm`: name of the parameter set, for example `"ML-KEM-768"` +- `public_key`: raw public key bytes +- `secret_key`: raw secret key bytes +- `seed`: the 64-byte seed `d || z`, or `None` unless the keypair was generated from a seed (secret) +- `public_key_hex`: hex string +- `secret_key_hex`: hex string +- `public_key_b64`: base64 string +- `secret_key_b64`: base64 string #### Methods -- `encapsulate() -> EncapsulationResult` — encapsulate using own public key -- `decapsulate(ciphertext: bytes) -> bytes` — decapsulate using own secret key -- `to_dict() -> dict` — `{"public_key": ..., "secret_key": ...}` (hex) +- `encapsulate() -> MlKemEncapsulationResult`: encapsulate using own public key +- `decapsulate(ciphertext: bytes) -> bytes`: decapsulate using own secret key +- `to_dict() -> dict`: `{"public_key": ..., "secret_key": ...}` (hex; the seed is not included) #### Tuple unpacking @@ -77,22 +82,23 @@ public_key, secret_key = keypair # __iter__ yields (pk, sk) --- -### `EncapsulationResult` +### `MlKemEncapsulationResult` -Returned by `encapsulate()` and static `Kyber*.encapsulate()`. +Returned by `encapsulate()` and static `ML_KEM_*.encapsulate()`. #### Properties -- `ciphertext` — raw ciphertext bytes -- `shared_secret` — raw shared secret bytes (32 bytes) -- `ciphertext_hex` — hex string -- `shared_secret_hex` — hex string -- `ciphertext_b64` — base64 string -- `shared_secret_b64` — base64 string +- `algorithm`: name of the parameter set +- `ciphertext`: raw ciphertext bytes +- `shared_secret`: raw shared secret bytes (32 bytes) +- `ciphertext_hex`: hex string +- `shared_secret_hex`: hex string +- `ciphertext_b64`: base64 string +- `shared_secret_b64`: base64 string #### Methods -- `to_dict() -> dict` — `{"ciphertext": ..., "shared_secret": ...}` (hex) +- `to_dict() -> dict`: `{"ciphertext": ..., "shared_secret": ...}` (hex) #### Tuple unpacking @@ -106,35 +112,40 @@ ciphertext, shared_secret = result # __iter__ yields (ct, ss) ### `KyberError` -Subclass of `Exception`. Raised on: -- Invalid public key size -- Invalid ciphertext size -- Invalid secret key size -- Invalid seed size (not 64 bytes) +Subclass of `Exception`, and the base class of every error pykyber raises on invalid input. The name predates ML-KEM and was kept so that existing `except pykyber.KyberError` blocks keep working. + +Subclasses, all raised by the `ML_KEM_*` classes: + +| Exception | Raised when | +|-----------|-------------| +| `InvalidInputError` | an argument has the wrong type or length (key, ciphertext, seed) | +| `InvalidEncapsulationKeyError` | the encapsulation key fails the modulus check of FIPS 203 section 7.2 | +| `InvalidDecapsulationKeyError` | the decapsulation key fails the hash check of FIPS 203 section 7.3 | +| `RandomnessError` | the operating system random generator fails | +| `DecapsulationError` | decapsulation fails | +| `AlgorithmMismatchError` | an object of another algorithm or parameter set is passed (also a `TypeError`) | ```python try: - pykyber.Kyber768.encapsulate(b"too_short") + pykyber.ML_KEM_768.encapsulate(b"too_short") except pykyber.KyberError as e: print(e) ``` ---- +### `KyberDeprecationWarning` + +Subclass of `DeprecationWarning`, emitted once per process on the first use of a deprecated `Kyber*` class. See [Kyber (deprecated)](#kyber-deprecated) below. -## Low-level functions +--- -These are variant-generic (Kyber-768): +## Kyber (deprecated) -| Function | Returns | Description | -|----------|---------|-------------| -| `_generate_keypair(seed)` | `(pk, sk)` | Kyber-768 keypair | -| `_encapsulate(pk)` | `(ct, ss)` | Kyber-768 encapsulate | -| `_decapsulate(ct, sk)` | `ss` | Kyber-768 decapsulate | +`Kyber512`, `Kyber768` and `Kyber1024` implement round-3 CRYSTALS-Kyber, not ML-KEM. They are deprecated in favor of `ML_KEM_512`, `ML_KEM_768` and `ML_KEM_1024`, and are kept so that existing code and data keep working: their output is byte-identical to pykyber 1.0.5. Nothing has been removed or renamed. -Variant-specific low-level functions: +- The shared secrets, ciphertexts and keys of Kyber and ML-KEM are not interchangeable, even though the sizes are identical. Both sides of a connection must switch together. +- The first use of any Kyber entry point (constructor, `from_keys`, static `encapsulate` or `decapsulate`) emits one `KyberDeprecationWarning` per process. Importing pykyber does not warn, and `ML_KEM_*` never warns. Python hides `DeprecationWarning` unless it is attributed to `__main__`; run with `-W default::DeprecationWarning` to see it. +- The interface is the same as for ML-KEM, with `Keypair` and `EncapsulationResult` in place of `MlKemKeypair` and `MlKemEncapsulationResult`, no `seed` property, and an optional 64-byte `seed` argument for deterministic keys. +- The class constants have the same names and values as the ML-KEM table above, apart from `SEED_SIZE`. +- Low-level functions (private, kept for compatibility): `_generate_keypair`, `_encapsulate` and `_decapsulate` for Kyber-768, and `_keypair_{512,768,1024}`, `_encapsulate_{512,768,1024}` and `_decapsulate_{512,768,1024}`. -| Function | Kyber-512 | Kyber-768 | Kyber-1024 | -|----------|-----------|-----------|------------| -| keypair | `_keypair_512(seed)` | `_keypair_768(seed)` | `_keypair_1024(seed)` | -| encapsulate | `_encapsulate_512(pk)` | `_encapsulate_768(pk)` | `_encapsulate_1024(pk)` | -| decapsulate | `_decapsulate_512(ct, sk)` | `_decapsulate_768(ct, sk)` | `_decapsulate_1024(ct, sk)` | +See the [migration guide](migration.md) for how to switch. 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 945c3a8..77ad65b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,9 +1,12 @@ # pykyber docs -- [API Reference](api.md) — full class and function documentation +- [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 -- [README](../README.md) — installation and quick start -- [CHANGELOG](../CHANGELOG.md) — release history -- [LICENSE](../LICENSE) — public domain (Unlicense) +- [README](../README.md): installation and quick start +- [CHANGELOG](../CHANGELOG.md): release history +- [SECURITY](../SECURITY.md): reporting policy and findings log +- [LICENSE-MIT](../LICENSE-MIT) and [LICENSE-APACHE](../LICENSE-APACHE): use either one (version 1.0.5 was released under the Unlicense) diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 0000000..5b77652 --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,98 @@ +# Migrating from Kyber to ML-KEM + +pykyber 2.0 adds `ML_KEM_512`, `ML_KEM_768` and `ML_KEM_1024`, which implement ML-KEM as standardized in FIPS 203. `Kyber512`, `Kyber768` and `Kyber1024` implement round-3 CRYSTALS-Kyber, the draft that ML-KEM replaced. The Kyber classes still work and still produce exactly the output of pykyber 1.0.5, but they are deprecated. New code should use ML-KEM; NIST recommends ML-KEM-768 as the default parameter set (FIPS 203, section 8). + +The two families look alike but cannot be mixed, and a few details are easy to get wrong. + +## What changes + +### Not interoperable + +The two differ in how keys and secrets are derived: ML-KEM key generation hashes the parameter `k` into the seed, encapsulation does not pre-hash the message, and the shared secret does not depend on a hash of the ciphertext. A Kyber public key, secret key or ciphertext is not a valid input to ML-KEM in any meaningful way, and the other way round. Do not try to convert between them. + +### Both sides must switch + +A connection where one peer encapsulates with ML-KEM and the other decapsulates with Kyber (or the reverse) does not fail with an error. Decapsulation still returns 32 bytes, but they are not the secret the other peer holds, and whatever you derive from it will not match. Move both peers to the same family, and agree on the parameter set. If you cannot switch both at once, negotiate the algorithm in your protocol. + +### The shared secret changes + +Even for a fixed seed, ML-KEM and Kyber give different keys, ciphertexts and shared secrets. Anything derived from a Kyber shared secret (session keys, stored wrapped keys, signatures over transcripts) has to be redone, and long-lived Kyber keys have to be replaced with newly generated ML-KEM keys. + +### Sizes are identical, so bytes cannot tell the two apart + +Public key, secret key and ciphertext sizes are the same for each security level (800/1632/768 for 512, 1184/2400/1088 for 768, 1568/3168/1568 for 1024), and the shared secret is 32 bytes. A stored or transmitted key does not say which family it belongs to. Record the algorithm next to the key (a field name, a file name, a version byte in your protocol); do not guess from the length. Because this is easy to get wrong, pykyber tags its objects: an `ML_KEM_*` method given a `Kyber*` keypair or result raises `AlgorithmMismatchError`, and so does an `ML_KEM_768` method given an `ML_KEM_512` object. Raw `bytes` carry no tag, so this check cannot protect you if you pass bytes. + +### The seed is 64 bytes, d || z + +`ML_KEM_*(seed=...)` takes a 64-byte seed made of two 32-byte halves, `d` and `z` (FIPS 203, Algorithm 16), and the same seed always gives the same keys. A keypair generated from a seed exposes it as `keypair.seed`; a randomly generated keypair, or one loaded with `from_keys`, has `seed` set to `None`. The seed is as sensitive as the secret key, because it determines it. The Kyber classes also take a 64-byte seed, but a Kyber seed and an ML-KEM seed of the same bytes give unrelated keys. + +## Code changes + +The classes have the same shape, so most code changes only the class name, plus `keypair.seed` if you use seeds. + +```python +import pykyber + +# Before (deprecated, round-3 Kyber) +alice = pykyber.Kyber768() +result = pykyber.Kyber768.encapsulate(alice.public_key) +assert alice.decapsulate(result.ciphertext) == result.shared_secret + +# After (ML-KEM, FIPS 203) +alice = pykyber.ML_KEM_768() +result = pykyber.ML_KEM_768.encapsulate(alice.public_key) +assert alice.decapsulate(result.ciphertext) == result.shared_secret +``` + +| Kyber | ML-KEM | +|-------|--------| +| `Kyber512` | `ML_KEM_512` | +| `Kyber768` | `ML_KEM_768` | +| `Kyber1024` | `ML_KEM_1024` | + +The constants (`PUBLIC_KEY_SIZE`, `SECRET_KEY_SIZE`, `CIPHERTEXT_SIZE`, `SHARED_SECRET_SIZE`), the static `encapsulate`, `decapsulate` and `from_keys`, the hex, base64 and dict helpers and tuple unpacking all work the same way. + +What is different besides the names: + +- ML-KEM checks keys when they are used: an encapsulation key that fails the modulus check of FIPS 203 section 7.2 raises `InvalidEncapsulationKeyError`, and a decapsulation key that fails the hash check of section 7.3 raises `InvalidDecapsulationKeyError`. Both are subclasses of `KyberError`, so existing `except pykyber.KyberError` blocks still catch them. +- The new errors `InvalidInputError`, `RandomnessError`, `DecapsulationError` and `AlgorithmMismatchError` are also subclasses of `KyberError`. +- Static `decapsulate` takes `(ciphertext, secret_key)` in both families. + +Mixing the families gives an error when the objects are tagged and silent garbage when they are raw bytes: + +```python +import pykyber + +kyber_keys = pykyber.Kyber768() +ml_kem_keys = pykyber.ML_KEM_768() + +# The objects are tagged, so mixing them is caught. +try: + pykyber.ML_KEM_768.encapsulate(kyber_keys) +except pykyber.AlgorithmMismatchError as e: + print(type(e).__name__) + +# Raw bytes are not tagged: this runs without an error and gives a secret +# that the holder of the Kyber secret key does not have. +result = pykyber.ML_KEM_768.encapsulate(kyber_keys.public_key) +print(kyber_keys.decapsulate(result.ciphertext) == result.shared_secret) # False +``` + +## Seeing the deprecation warning + +The first use of any Kyber entry point (the constructor, `from_keys`, static `encapsulate` or static `decapsulate`) emits one `KyberDeprecationWarning` per process. It is a subclass of `DeprecationWarning`, and Python hides `DeprecationWarning` by default unless the code that triggers it is in `__main__`. A call made from inside a library or from a test runner is therefore silent unless you ask for it: + +```bash +python -W default::DeprecationWarning your_script.py +``` + +or, inside Python, `warnings.simplefilter("default", pykyber.KyberDeprecationWarning)`. To find every remaining use, make it an error with `warnings.simplefilter("error", pykyber.KyberDeprecationWarning)`. Importing pykyber does not warn, and `ML_KEM_*` never warns. + +## Checklist + +1. Choose the parameter set (`ML_KEM_768` unless you have a reason to differ). +2. Change both peers, or add algorithm negotiation to your protocol. +3. Generate new ML-KEM keys; do not reuse Kyber keys or seeds. +4. Re-derive everything that was derived from a Kyber shared secret. +5. Store the algorithm name next to every key and ciphertext. +6. Treat `keypair.seed` like the secret key. 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 2cc3c87..5377ca0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,11 +4,12 @@ build-backend = "maturin" [project] name = "pykyber" -version = "1.0.5" -description = "Kyber post-quantum key encapsulation in Rust" +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,14 +22,13 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Rust", - "License :: Public Domain", "Operating System :: OS Independent", "Topic :: Security :: Cryptography", ] [project.urls] Homepage = "https://github.com/Zikithezikit/pykyber" -Documentation = "https://github.com/Zikithezikit/pykyber/tree/v1.0.5/docs" +Documentation = "https://github.com/Zikithezikit/pykyber/tree/v2.0.0/docs" Repository = "https://github.com/Zikithezikit/pykyber" Issues = "https://github.com/Zikithezikit/pykyber/issues" diff --git a/python/pykyber/__init__.py b/python/pykyber/__init__.py index 5e5e15a..47d3e24 100644 --- a/python/pykyber/__init__.py +++ b/python/pykyber/__init__.py @@ -4,6 +4,16 @@ +from ._error import ( + AlgorithmMismatchError, + DecapsulationError, + InvalidDecapsulationKeyError, + InvalidEncapsulationKeyError, + InvalidInputError, + RandomnessError, +) +from ._deprecation import KyberDeprecationWarning +from ._mlkem import ML_KEM_512, ML_KEM_768, ML_KEM_1024 from ._kyber import ( Kyber768, Kyber512, @@ -14,7 +24,22 @@ _validate_keypair, ) -__all__ = ["Kyber512", "Kyber768", "Kyber1024", "KyberError"] +__all__ = [ + "Kyber512", + "Kyber768", + "Kyber1024", + "ML_KEM_512", + "ML_KEM_768", + "ML_KEM_1024", + "KyberError", + "KyberDeprecationWarning", + "AlgorithmMismatchError", + "DecapsulationError", + "InvalidDecapsulationKeyError", + "InvalidEncapsulationKeyError", + "InvalidInputError", + "RandomnessError", +] from . import _pykyber diff --git a/python/pykyber/_deprecation.py b/python/pykyber/_deprecation.py new file mode 100644 index 0000000..fcf6776 --- /dev/null +++ b/python/pykyber/_deprecation.py @@ -0,0 +1,51 @@ +"""Deprecation warning for the legacy round-3 Kyber classes.""" + +import functools +import threading +import warnings + +MIGRATION_GUIDE_URL = "https://github.com/Zikithezikit/pykyber/blob/main/docs/migration.md" + +# warnings.warn() frames to skip: 1 is _warn_once, 2 is the wrapper made by +# _deprecated_kyber, 3 is the user's code that called the Kyber entry point. +_CALLER_STACKLEVEL = 3 + +_MESSAGE = ( + "Kyber512, Kyber768 and Kyber1024 implement round-3 CRYSTALS-Kyber, which is " + "deprecated in favor of ML-KEM (FIPS 203): ML_KEM_512, ML_KEM_768 and " + "ML_KEM_1024. Kyber (round 3) and ML-KEM are not interoperable, so both sides " + "must switch, and switching changes the shared secret. " + f"See {MIGRATION_GUIDE_URL}" +) + +_lock = threading.Lock() +_warned = False + + +class KyberDeprecationWarning(DeprecationWarning): + """The legacy round-3 Kyber classes are deprecated in favor of ML-KEM.""" + + +def _warn_once() -> None: + """Warn on the first call in this process, then do nothing. + + The flag is our own: the default warning filter keeps one registry entry per + call site, so it would repeat the warning for every new location. + """ + global _warned + with _lock: + if _warned: + return + _warned = True + warnings.warn(_MESSAGE, KyberDeprecationWarning, stacklevel=_CALLER_STACKLEVEL) + + +def _deprecated_kyber(func): + """Decorate a Kyber entry point so its first use warns.""" + + @functools.wraps(func) + def wrapper(*args, **kwargs): + _warn_once() + return func(*args, **kwargs) + + return wrapper diff --git a/python/pykyber/_error.py b/python/pykyber/_error.py index 997c1da..f6eb21f 100644 --- a/python/pykyber/_error.py +++ b/python/pykyber/_error.py @@ -4,3 +4,35 @@ class KyberError(Exception): """Kyber error exception - raised when invalid input is provided.""" pass + + +# The classes below are what the Rust bindings raise for ML-KEM; the Rust side +# looks them up by name in this module (src/pymlkem.rs, `error_class`). + + +class InvalidInputError(KyberError): + """An argument has the wrong type or length.""" + + +class InvalidEncapsulationKeyError(KyberError): + """The encapsulation key failed the modulus check of FIPS 203 section 7.2.""" + + +class InvalidDecapsulationKeyError(KyberError): + """The decapsulation key failed the hash check of FIPS 203 section 7.3.""" + + +class RandomnessError(KyberError): + """The operating system random generator failed.""" + + +class DecapsulationError(KyberError): + """Decapsulation failed.""" + + +class AlgorithmMismatchError(KyberError, TypeError): + """An object of another algorithm or parameter set was passed. + + Kyber and ML-KEM keys, ciphertexts and shared secrets have identical sizes, + so only the object type can tell them apart. + """ diff --git a/python/pykyber/_kyber.py b/python/pykyber/_kyber.py index 877a06d..ea9225e 100644 --- a/python/pykyber/_kyber.py +++ b/python/pykyber/_kyber.py @@ -1,9 +1,21 @@ """Kyber post-quantum key encapsulation.""" from typing import Optional +from ._deprecation import _deprecated_kyber from ._error import KyberError from ._types import EncapsulationResult, Keypair -from ._validation import _validate_encapsulate, _validate_decapsulate, _validate_keypair, _validate_pk +from ._validation import ( + _validate_decapsulate, + _validate_encapsulate, + _validate_key_binding, + _validate_keypair, + _validate_pk, +) + +# Bytes of one serialized polynomial (ByteEncode12 of 256 coefficients). +_POLY_BYTES = 384 +# Module rank k: the IND-CPA secret key s of a round-3 Kyber secret key is k polynomials. +_K_512, _K_768, _K_1024 = 2, 3, 4 class Kyber512: @@ -14,6 +26,7 @@ class Kyber512: CIPHERTEXT_SIZE = 768 SHARED_SECRET_SIZE = 32 + @_deprecated_kyber def __new__(cls, seed: Optional[bytes] = None) -> Keypair: """Generate a new key pair, optionally from a 64-byte seed.""" from . import _pykyber @@ -23,22 +36,29 @@ def __new__(cls, seed: Optional[bytes] = None) -> Keypair: _validate_decapsulate(_pykyber._decapsulate_512, Kyber512.CIPHERTEXT_SIZE, "Kyber512")) @staticmethod + @_deprecated_kyber def encapsulate(public_key: bytes) -> EncapsulationResult: """Encapsulate a shared secret using a public key (no keypair needed).""" from . import _pykyber return EncapsulationResult(*_validate_encapsulate(_pykyber._encapsulate_512, Kyber512.PUBLIC_KEY_SIZE, "Kyber512.encapsulate")(public_key)) @staticmethod + @_deprecated_kyber def decapsulate(ciphertext: bytes, secret_key: bytes) -> bytes: """Decapsulate a shared secret using ciphertext and secret key (no keypair needed).""" from . import _pykyber return _validate_decapsulate(_pykyber._decapsulate_512, Kyber512.CIPHERTEXT_SIZE, "Kyber512.decapsulate")(ciphertext, secret_key) @classmethod + @_deprecated_kyber def from_keys(cls, public_key: bytes, secret_key: bytes) -> Keypair: - """Create a Keypair object from existing public and secret keys.""" + """Create a Keypair object from existing public and secret keys. + + Raises KyberError if the public key is not the one stored inside the secret key. + """ from . import _pykyber _validate_pk(public_key, cls.PUBLIC_KEY_SIZE, f"{cls.__name__}.from_keys") + _validate_key_binding(public_key, secret_key, cls.SECRET_KEY_SIZE, _K_512 * _POLY_BYTES, f"{cls.__name__}.from_keys") return Keypair(secret_key, public_key, _validate_encapsulate(_pykyber._encapsulate_512, cls.PUBLIC_KEY_SIZE, cls.__name__), _validate_decapsulate(_pykyber._decapsulate_512, cls.CIPHERTEXT_SIZE, cls.__name__)) @@ -52,6 +72,7 @@ class Kyber768: CIPHERTEXT_SIZE = 1088 SHARED_SECRET_SIZE = 32 + @_deprecated_kyber def __new__(cls, seed: Optional[bytes] = None) -> Keypair: """Generate a new key pair, optionally from a 64-byte seed.""" from . import _pykyber @@ -61,22 +82,29 @@ def __new__(cls, seed: Optional[bytes] = None) -> Keypair: _validate_decapsulate(_pykyber._decapsulate_768, Kyber768.CIPHERTEXT_SIZE, "Kyber768")) @staticmethod + @_deprecated_kyber def encapsulate(public_key: bytes) -> EncapsulationResult: """Encapsulate a shared secret using a public key (no keypair needed).""" from . import _pykyber return EncapsulationResult(*_validate_encapsulate(_pykyber._encapsulate_768, Kyber768.PUBLIC_KEY_SIZE, "Kyber768.encapsulate")(public_key)) @staticmethod + @_deprecated_kyber def decapsulate(ciphertext: bytes, secret_key: bytes) -> bytes: """Decapsulate a shared secret using ciphertext and secret key (no keypair needed).""" from . import _pykyber return _validate_decapsulate(_pykyber._decapsulate_768, Kyber768.CIPHERTEXT_SIZE, "Kyber768.decapsulate")(ciphertext, secret_key) @classmethod + @_deprecated_kyber def from_keys(cls, public_key: bytes, secret_key: bytes) -> Keypair: - """Create a Keypair object from existing public and secret keys.""" + """Create a Keypair object from existing public and secret keys. + + Raises KyberError if the public key is not the one stored inside the secret key. + """ from . import _pykyber _validate_pk(public_key, cls.PUBLIC_KEY_SIZE, f"{cls.__name__}.from_keys") + _validate_key_binding(public_key, secret_key, cls.SECRET_KEY_SIZE, _K_768 * _POLY_BYTES, f"{cls.__name__}.from_keys") return Keypair(secret_key, public_key, _validate_encapsulate(_pykyber._encapsulate_768, cls.PUBLIC_KEY_SIZE, cls.__name__), _validate_decapsulate(_pykyber._decapsulate_768, cls.CIPHERTEXT_SIZE, cls.__name__)) @@ -90,6 +118,7 @@ class Kyber1024: CIPHERTEXT_SIZE = 1568 SHARED_SECRET_SIZE = 32 + @_deprecated_kyber def __new__(cls, seed: Optional[bytes] = None) -> Keypair: """Generate a new key pair, optionally from a 64-byte seed.""" from . import _pykyber @@ -99,22 +128,29 @@ def __new__(cls, seed: Optional[bytes] = None) -> Keypair: _validate_decapsulate(_pykyber._decapsulate_1024, Kyber1024.CIPHERTEXT_SIZE, "Kyber1024")) @staticmethod + @_deprecated_kyber def encapsulate(public_key: bytes) -> EncapsulationResult: """Encapsulate a shared secret using a public key (no keypair needed).""" from . import _pykyber return EncapsulationResult(*_validate_encapsulate(_pykyber._encapsulate_1024, Kyber1024.PUBLIC_KEY_SIZE, "Kyber1024.encapsulate")(public_key)) @staticmethod + @_deprecated_kyber def decapsulate(ciphertext: bytes, secret_key: bytes) -> bytes: """Decapsulate a shared secret using ciphertext and secret key (no keypair needed).""" from . import _pykyber return _validate_decapsulate(_pykyber._decapsulate_1024, Kyber1024.CIPHERTEXT_SIZE, "Kyber1024.decapsulate")(ciphertext, secret_key) @classmethod + @_deprecated_kyber def from_keys(cls, public_key: bytes, secret_key: bytes) -> Keypair: - """Create a Keypair object from existing public and secret keys.""" + """Create a Keypair object from existing public and secret keys. + + Raises KyberError if the public key is not the one stored inside the secret key. + """ from . import _pykyber _validate_pk(public_key, cls.PUBLIC_KEY_SIZE, f"{cls.__name__}.from_keys") + _validate_key_binding(public_key, secret_key, cls.SECRET_KEY_SIZE, _K_1024 * _POLY_BYTES, f"{cls.__name__}.from_keys") return Keypair(secret_key, public_key, _validate_encapsulate(_pykyber._encapsulate_1024, cls.PUBLIC_KEY_SIZE, cls.__name__), _validate_decapsulate(_pykyber._decapsulate_1024, cls.CIPHERTEXT_SIZE, cls.__name__)) diff --git a/python/pykyber/_mlkem.py b/python/pykyber/_mlkem.py new file mode 100644 index 0000000..1745815 --- /dev/null +++ b/python/pykyber/_mlkem.py @@ -0,0 +1,123 @@ +"""ML-KEM (FIPS 203) key encapsulation: ML_KEM_512, ML_KEM_768 and ML_KEM_1024. + +ML-KEM is not round-3 Kyber. The keys have the same sizes, but the algorithms +differ, so the shared secrets, ciphertexts and keys of the two families are +not interchangeable. Raw ``bytes`` cannot be told apart; the Kyber and ML-KEM +objects can, and an ML-KEM method refuses a Kyber object. + +Secrets cross from Rust into immutable Python ``bytes`` objects that cannot be +wiped. Rust zeroizes its own copies, but the ``bytes`` you get back (secret +key, shared secret, seed) stay in memory until the interpreter frees them. +""" + +from typing import Optional, Union + +from . import _pykyber +from ._types import MlKemEncapsulationResult, MlKemKeypair +from ._validation import _validate_ml_kem_bytes, _validate_ml_kem_seed + +# FIPS 203 section 2.4 and Table 2. +_N = 256 # coefficients per polynomial +_BITS_PER_BYTE = 8 +_POLY_BITS = 12 # ByteEncode12 of the keys +_SEED_HALF_BYTES = 32 # d, z, m, rho, H(ek) +_POLY_BYTES = _N * _POLY_BITS // _BITS_PER_BYTE + + +def _sizes(k: int, du: int, dv: int) -> tuple: + """(ek, dk, c) sizes in bytes for the parameters of FIPS 203 Table 2.""" + ek = _POLY_BYTES * k + _SEED_HALF_BYTES # t-hat || rho + dk = _POLY_BYTES * k + ek + 2 * _SEED_HALF_BYTES # dk_PKE || ek || H(ek) || z (Algorithm 16) + ct = _N * (du * k + dv) // _BITS_PER_BYTE # c1 || c2 (Algorithm 14) + return ek, dk, ct + + +_Key = Union[bytes, MlKemKeypair] + + +class _MlKem: + """Shared behaviour of the three parameter sets.""" + + ALGORITHM: str + PUBLIC_KEY_SIZE: int + SECRET_KEY_SIZE: int + CIPHERTEXT_SIZE: int + SHARED_SECRET_SIZE = _SEED_HALF_BYTES + SEED_SIZE = 2 * _SEED_HALF_BYTES # d || z + + # The private Rust functions, set per parameter set. + _keygen: staticmethod + _encaps: staticmethod + _decaps: staticmethod + + def __new__(cls, seed: Optional[bytes] = None) -> MlKemKeypair: + """Generate a new key pair, optionally from a 64-byte seed ``d || z``. + + The seed is secret: it determines the secret key. The keypair has a + ``seed`` attribute only if it was generated from one. + """ + seed = _validate_ml_kem_seed(seed, cls.SEED_SIZE, cls.ALGORITHM) + public_key, secret_key = cls._keygen(seed) + return MlKemKeypair(cls, public_key, secret_key, seed) + + @classmethod + def encapsulate(cls, public_key: _Key) -> MlKemEncapsulationResult: + """Encapsulate a shared secret using a public key (no keypair needed).""" + method = cls.ALGORITHM + ".encapsulate" + public_key = _validate_ml_kem_bytes(public_key, "public_key", cls.ALGORITHM, cls.PUBLIC_KEY_SIZE, method) + ciphertext, shared_secret = cls._encaps(public_key) + return MlKemEncapsulationResult(cls.ALGORITHM, ciphertext, shared_secret) + + @classmethod + def decapsulate(cls, ciphertext: Union[bytes, MlKemEncapsulationResult], secret_key: _Key) -> bytes: + """Decapsulate a shared secret using ciphertext and secret key (no keypair needed). + + A well-formed ciphertext that was not made for this key does not raise: + it yields a different, pseudorandom secret (implicit rejection). + """ + method = cls.ALGORITHM + ".decapsulate" + ciphertext = _validate_ml_kem_bytes(ciphertext, "ciphertext", cls.ALGORITHM, cls.CIPHERTEXT_SIZE, method) + secret_key = _validate_ml_kem_bytes(secret_key, "secret_key", cls.ALGORITHM, cls.SECRET_KEY_SIZE, method) + return cls._decaps(ciphertext, secret_key) + + @classmethod + def from_keys(cls, public_key: _Key, secret_key: _Key) -> MlKemKeypair: + """Create a Keypair object from existing public and secret keys. + + Only the sizes are checked here; the checks of FIPS 203 sections 7.2 + and 7.3 run when the keys are used. + """ + method = cls.ALGORITHM + ".from_keys" + public_key = _validate_ml_kem_bytes(public_key, "public_key", cls.ALGORITHM, cls.PUBLIC_KEY_SIZE, method) + secret_key = _validate_ml_kem_bytes(secret_key, "secret_key", cls.ALGORITHM, cls.SECRET_KEY_SIZE, method) + return MlKemKeypair(cls, public_key, secret_key) + + +class ML_KEM_512(_MlKem): + """ML-KEM-512 (FIPS 203, security category 1).""" + + ALGORITHM = "ML-KEM-512" + PUBLIC_KEY_SIZE, SECRET_KEY_SIZE, CIPHERTEXT_SIZE = _sizes(k=2, du=10, dv=4) + _keygen = staticmethod(_pykyber._mlkem_512_keygen) + _encaps = staticmethod(_pykyber._mlkem_512_encaps) + _decaps = staticmethod(_pykyber._mlkem_512_decaps) + + +class ML_KEM_768(_MlKem): + """ML-KEM-768 (FIPS 203, security category 3; NIST's recommended default).""" + + ALGORITHM = "ML-KEM-768" + PUBLIC_KEY_SIZE, SECRET_KEY_SIZE, CIPHERTEXT_SIZE = _sizes(k=3, du=10, dv=4) + _keygen = staticmethod(_pykyber._mlkem_768_keygen) + _encaps = staticmethod(_pykyber._mlkem_768_encaps) + _decaps = staticmethod(_pykyber._mlkem_768_decaps) + + +class ML_KEM_1024(_MlKem): + """ML-KEM-1024 (FIPS 203, security category 5).""" + + ALGORITHM = "ML-KEM-1024" + PUBLIC_KEY_SIZE, SECRET_KEY_SIZE, CIPHERTEXT_SIZE = _sizes(k=4, du=11, dv=5) + _keygen = staticmethod(_pykyber._mlkem_1024_keygen) + _encaps = staticmethod(_pykyber._mlkem_1024_encaps) + _decaps = staticmethod(_pykyber._mlkem_1024_decaps) diff --git a/python/pykyber/_types.py b/python/pykyber/_types.py index 6cbfc3c..0643b31 100644 --- a/python/pykyber/_types.py +++ b/python/pykyber/_types.py @@ -2,7 +2,7 @@ import base64 from dataclasses import dataclass -from typing import Tuple, Callable, Dict, Any +from typing import Tuple, Callable, Dict, Any, Optional @dataclass(frozen=True) @@ -110,3 +110,137 @@ def __repr__(self) -> str: def __iter__(self): yield self._public_key yield self._secret_key + + +# ML-KEM objects are separate classes, not subclasses of the Kyber ones above: +# Kyber and ML-KEM keys, ciphertexts and shared secrets have identical sizes, +# so the type is the only thing that tells them apart (see _validation.py). + + +class MlKemEncapsulationResult: + """Result of an ML-KEM encapsulation: ``ciphertext, shared_secret = result``.""" + + __slots__ = ("algorithm", "ciphertext", "shared_secret") + + def __init__(self, algorithm: str, ciphertext: bytes, shared_secret: bytes) -> None: + self.algorithm = algorithm + self.ciphertext = ciphertext + self.shared_secret = shared_secret + + @property + def ciphertext_hex(self) -> str: + """Return the ciphertext in hex format.""" + return self.ciphertext.hex() + + @property + def shared_secret_hex(self) -> str: + """Return the shared secret in hex format.""" + return self.shared_secret.hex() + + @property + def ciphertext_b64(self) -> str: + """Return the ciphertext in base64 format.""" + return base64.b64encode(self.ciphertext).decode("utf-8") + + @property + def shared_secret_b64(self) -> str: + """Return the shared secret in base64 format.""" + return base64.b64encode(self.shared_secret).decode("utf-8") + + def to_dict(self) -> Dict[str, Any]: + """Convert result to a dictionary (using hex encoding).""" + return { + "ciphertext": self.ciphertext_hex, + "shared_secret": self.shared_secret_hex, + } + + def __repr__(self) -> str: + return ( + f"MlKemEncapsulationResult({self.algorithm}, " + f"ciphertext={len(self.ciphertext)} bytes, " + f"shared_secret={len(self.shared_secret)} bytes)" + ) + + def __iter__(self): + yield self.ciphertext + yield self.shared_secret + + +class MlKemKeypair: + """An ML-KEM keypair: ``public_key, secret_key = keypair``. + + ``seed`` is the 64-byte ``d || z`` the keys were generated from, or None + when they came from the random generator or from ``from_keys``. + """ + + __slots__ = ("algorithm", "_cls", "_public_key", "_secret_key", "_seed") + + def __init__(self, cls: Any, public_key: bytes, secret_key: bytes, seed: Optional[bytes] = None) -> None: + self.algorithm = cls.ALGORITHM + self._cls = cls + self._public_key = public_key + self._secret_key = secret_key + self._seed = seed + + def encapsulate(self) -> MlKemEncapsulationResult: + """Encapsulate a shared secret using the public key.""" + return self._cls.encapsulate(self._public_key) + + def decapsulate(self, ciphertext: Any) -> bytes: + """Decapsulate a shared secret using ciphertext.""" + return self._cls.decapsulate(ciphertext, self._secret_key) + + @property + def secret_key(self) -> bytes: + """Return the raw secret key bytes.""" + return self._secret_key + + @property + def public_key(self) -> bytes: + """Return the raw public key bytes.""" + return self._public_key + + @property + def seed(self) -> Optional[bytes]: + """The 64-byte seed ``d || z`` (secret, as sensitive as the secret key). + + None unless the keypair was generated from a seed. + """ + return self._seed + + @property + def public_key_hex(self) -> str: + """Return the public key in hex format.""" + return self._public_key.hex() + + @property + def secret_key_hex(self) -> str: + """Return the secret key in hex format.""" + return self._secret_key.hex() + + @property + def public_key_b64(self) -> str: + """Return the public key in base64 format.""" + return base64.b64encode(self._public_key).decode("utf-8") + + @property + def secret_key_b64(self) -> str: + """Return the secret key in base64 format.""" + return base64.b64encode(self._secret_key).decode("utf-8") + + def to_dict(self) -> Dict[str, Any]: + """Convert keypair to a dictionary (using hex encoding); the seed is left out.""" + return { + "public_key": self.public_key_hex, + "secret_key": self.secret_key_hex, + } + + def __repr__(self) -> str: + return ( + f"MlKemKeypair({self.algorithm}, secret_key={len(self._secret_key)} bytes, " + f"public_key={len(self._public_key)} bytes)" + ) + + def __iter__(self): + yield self._public_key + yield self._secret_key diff --git a/python/pykyber/_validation.py b/python/pykyber/_validation.py index 2b078b9..6777348 100644 --- a/python/pykyber/_validation.py +++ b/python/pykyber/_validation.py @@ -2,7 +2,8 @@ from typing import Tuple, Callable, Optional -from ._error import KyberError +from ._error import AlgorithmMismatchError, InvalidInputError, KyberError +from ._types import EncapsulationResult, Keypair, MlKemEncapsulationResult, MlKemKeypair def _validate_pk(pk: bytes, expected_size: int, method_name: str) -> None: @@ -15,6 +16,31 @@ def _validate_pk(pk: bytes, expected_size: int, method_name: str) -> None: ) +def _validate_key_binding( + public_key: bytes, + secret_key: bytes, + secret_key_size: int, + pke_secret_key_size: int, + method_name: str, +) -> None: + """Reject a public key that is not the copy embedded in the secret key. + + A round-3 Kyber secret key is ``sk_pke || pk || H(pk) || z`` (``pk`` starts at + ``pke_secret_key_size``), so a pair of keys from different key pairs can be + detected. Both sides are public data, so a plain comparison is fine. A secret + key of the wrong size is left alone: it fails its size check when it is used. + """ + if len(secret_key) != secret_key_size: + return + embedded = bytes(secret_key[pke_secret_key_size:pke_secret_key_size + len(public_key)]) + if bytes(public_key) != embedded: + raise KyberError( + f"Invalid key pair for {method_name}: the public key does not match the " + f"public key stored inside the secret key. Use the public key and the " + f"secret key of the same key pair." + ) + + def _validate_seed(seed: Optional[bytes], expected_size: int = 64) -> None: """Validate seed length.""" if seed is not None: @@ -31,6 +57,9 @@ def wrapper(pk: bytes) -> Tuple[bytes, bytes]: _validate_pk(pk, expected_pk_size, method_name) try: return fn(pk) + except KyberError: + # Keep the subclass, e.g. RandomnessError when the OS generator fails. + raise except Exception as e: raise KyberError(str(e)) from None return wrapper @@ -62,6 +91,52 @@ def wrapper(seed: Optional[bytes] = None) -> Tuple[bytes, bytes]: _validate_seed(seed) try: return fn(seed) + except KyberError: + raise except Exception as e: raise KyberError(str(e)) from None return wrapper + + +def _validate_ml_kem_bytes(value: object, kind: str, algorithm: str, expected_size: int, method_name: str) -> bytes: + """Return the ``kind`` bytes ("public_key", "secret_key" or "ciphertext") of ``value``. + + ``value`` is ``bytes`` or an ML-KEM object of the same ``algorithm``. A + Kyber object, or an ML-KEM object of another parameter set, is refused by + type: Kyber and ML-KEM keys have identical sizes, so a length check cannot + catch the mix-up. Raw bytes cannot be told apart and are taken as given. + """ + if isinstance(value, (Keypair, EncapsulationResult)): + raise AlgorithmMismatchError( + f"{method_name} was given a Kyber {type(value).__name__} object, but {algorithm} is not " + f"round-3 Kyber: the sizes match, the keys and secrets are not interchangeable." + ) + if isinstance(value, (MlKemKeypair, MlKemEncapsulationResult)): + if value.algorithm != algorithm: + raise AlgorithmMismatchError( + f"{method_name} was given a {value.algorithm} object, but this is {algorithm}." + ) + value = getattr(value, kind, None) + if not isinstance(value, bytes): + raise InvalidInputError( + f"Invalid {kind} for {method_name}: expected bytes or a {algorithm} object holding one, " + f"got {type(value).__name__}." + ) + if len(value) != expected_size: + raise InvalidInputError( + f"Invalid {kind} for {method_name}: expected {expected_size} bytes, got {len(value)}." + ) + return value + + +def _validate_ml_kem_seed(seed: Optional[bytes], expected_size: int, method_name: str) -> Optional[bytes]: + """Check the optional 64-byte seed ``d || z`` (bytes only) and return it.""" + if seed is None: + return None + if not isinstance(seed, bytes): + raise InvalidInputError(f"Invalid seed for {method_name}: expected bytes, got {type(seed).__name__}.") + if len(seed) != expected_size: + raise InvalidInputError( + f"Invalid seed for {method_name}: expected {expected_size} bytes, got {len(seed)}." + ) + return seed diff --git a/python/tests/test_kyber.py b/python/tests/test_kyber.py index 1b787d3..1247f22 100644 --- a/python/tests/test_kyber.py +++ b/python/tests/test_kyber.py @@ -1570,3 +1570,75 @@ def test_keypair_as_tuple_in_function(): decrypted = pykyber.Kyber768.decapsulate(ciphertext, sk) assert shared_secret == decrypted + + +class TestRandomnessFailure: + """A failing OS generator reaches the caller as RandomnessError, a KyberError.""" + + @staticmethod + def _failing(*_args): + raise pykyber.RandomnessError("Random bytes generation failed") + + def test_keypair_wrapper_keeps_randomness_error(self): + from pykyber._validation import _validate_keypair + + with pytest.raises(pykyber.RandomnessError) as excinfo: + _validate_keypair(self._failing)() + assert isinstance(excinfo.value, pykyber.KyberError) + + def test_encapsulate_wrapper_keeps_randomness_error(self): + from pykyber._validation import _validate_encapsulate + + with pytest.raises(pykyber.RandomnessError): + _validate_encapsulate(self._failing, 1184, "Kyber768")(bytes(1184)) + + def test_other_errors_are_still_wrapped(self): + from pykyber._validation import _validate_keypair + + def boom(_seed): + raise ValueError("boom") + + with pytest.raises(pykyber.KyberError) as excinfo: + _validate_keypair(boom)() + assert not isinstance(excinfo.value, pykyber.RandomnessError) + + +# from_keys must bind the public key to the secret key it is paired with. + +_LEVELS = [pykyber.Kyber512, pykyber.Kyber768, pykyber.Kyber1024] + + +@pytest.mark.parametrize("cls", _LEVELS) +class TestFromKeysBinding: + def test_matching_pair_is_accepted(self, cls): + original = cls() + rebuilt = cls.from_keys(original.public_key, original.secret_key) + result = rebuilt.encapsulate() + assert original.decapsulate(result.ciphertext) == result.shared_secret + assert rebuilt.decapsulate(result.ciphertext) == result.shared_secret + + def test_mismatched_pair_is_rejected(self, cls): + a, b = cls(), cls() + with pytest.raises(pykyber.KyberError, match="does not match"): + cls.from_keys(a.public_key, b.secret_key) + with pytest.raises(pykyber.KyberError, match="does not match"): + cls.from_keys(b.public_key, a.secret_key) + + @pytest.mark.parametrize("position", [0, -1]) + def test_one_changed_byte_of_the_public_key_is_rejected(self, cls, position): + pair = cls() + pk = bytearray(pair.public_key) + pk[position] ^= 1 + with pytest.raises(pykyber.KyberError, match="does not match"): + cls.from_keys(bytes(pk), pair.secret_key) + + def test_short_public_key_keeps_its_size_error(self, cls): + pair = cls() + with pytest.raises(pykyber.KyberError, match="Invalid public key"): + cls.from_keys(pair.public_key[:-1], pair.secret_key) + + def test_short_secret_key_keeps_its_size_error(self, cls): + pair = cls() + short = cls.from_keys(pair.public_key, pair.secret_key[:-1]) + with pytest.raises(pykyber.KyberError, match="Invalid input for 'sk'"): + short.decapsulate(pair.encapsulate().ciphertext) diff --git a/python/tests/test_kyber_deprecation.py b/python/tests/test_kyber_deprecation.py new file mode 100644 index 0000000..40a427b --- /dev/null +++ b/python/tests/test_kyber_deprecation.py @@ -0,0 +1,156 @@ +"""The Kyber* classes warn once per process (KyberDeprecationWarning) and +nothing else changes. Counting is done in subprocesses, because the flag is +per process and pytest would otherwise share it across the whole run.""" + +import json +import os +import subprocess +import sys +import textwrap + +import pytest + +import pykyber +from pykyber import _deprecation + +KYBER_CLASSES = ["Kyber512", "Kyber768", "Kyber1024"] +ENTRY_POINTS = ["new", "from_keys", "encapsulate", "decapsulate"] +MIGRATION_GUIDE_URL = "https://github.com/Zikithezikit/pykyber/blob/main/docs/migration.md" +REPEATS = 3 # uses of every class and entry point per child process +CHILD_TIMEOUT_SECONDS = 120 + +# Records every warning the child sees, one JSON object per warning on stdout. +# {body} runs under a "record everything" filter so the child reports what is +# really emitted, whatever filters the parent passed with -W. +CHILD_TEMPLATE = textwrap.dedent( + """ + import json, sys, warnings + import pykyber + with warnings.catch_warnings(record=True) as seen: + warnings.simplefilter("always") + {body} + print(json.dumps([[w.category.__name__, str(w.message), w.filename] for w in seen])) + """ +) + +KYBER_BODY = """ + for _ in range({repeats}): + for name in {classes!r}: + cls = getattr(pykyber, name) + kp = cls() + res = cls.encapsulate(kp.public_key) + cls.decapsulate(res.ciphertext, kp.secret_key) + cls.from_keys(kp.public_key, kp.secret_key) +""" + +MLKEM_BODY = """ + for name in ("ML_KEM_512", "ML_KEM_768", "ML_KEM_1024"): + cls = getattr(pykyber, name) + kp = cls() + res = cls.encapsulate(kp.public_key) + cls.decapsulate(res.ciphertext, kp.secret_key) + cls.from_keys(kp.public_key, kp.secret_key) +""" + + +def run_child(body, *flags): + """Run CHILD_TEMPLATE with body in a fresh interpreter and return its warnings.""" + code = CHILD_TEMPLATE.replace("{body}", textwrap.indent(textwrap.dedent(body), " ")) + env = {k: v for k, v in os.environ.items() if k != "PYTHONWARNINGS"} + proc = subprocess.run( + [sys.executable, *flags, "-c", code], + capture_output=True, + text=True, + timeout=CHILD_TIMEOUT_SECONDS, + env=env, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout.strip().splitlines()[-1]) + + +def kyber_body(): + return KYBER_BODY.format(repeats=REPEATS, classes=KYBER_CLASSES) + + +@pytest.mark.parametrize("flag", ["always::DeprecationWarning", "default"]) +def test_many_uses_warn_exactly_once(flag): + seen = run_child(kyber_body(), "-W", flag) + assert [category for category, _, _ in seen] == ["KyberDeprecationWarning"] + + +def test_import_alone_warns_nothing(): + assert run_child("pass", "-W", "always::DeprecationWarning") == [] + + +def test_ml_kem_never_warns(): + assert run_child(MLKEM_BODY, "-W", "always::DeprecationWarning") == [] + + +def test_message_content(): + ((_, message, _),) = run_child(kyber_body()) + assert "not interoperable" in message + assert "both sides must switch" in message + assert "changes the shared secret" in message + assert MIGRATION_GUIDE_URL in message + + +def test_library_caller_is_silent_by_default(tmp_path): + """Python hides DeprecationWarning unless it is attributed to __main__, so a + call made from an imported module prints nothing under the default filters.""" + (tmp_path / "uses_kyber.py").write_text("import pykyber\npykyber.Kyber768()\n") + code = f"import sys; sys.path.insert(0, {str(tmp_path)!r}); import uses_kyber" + env = {k: v for k, v in os.environ.items() if k != "PYTHONWARNINGS"} + proc = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + timeout=CHILD_TIMEOUT_SECONDS, + env=env, + ) + assert proc.returncode == 0 + assert "KyberDeprecationWarning" not in proc.stderr + assert proc.stderr == "" + + +def test_main_caller_is_shown_by_default(): + env = {k: v for k, v in os.environ.items() if k != "PYTHONWARNINGS"} + proc = subprocess.run( + [sys.executable, "-c", "import pykyber\npykyber.Kyber768()"], + capture_output=True, + text=True, + timeout=CHILD_TIMEOUT_SECONDS, + env=env, + ) + assert proc.returncode == 0 + assert "KyberDeprecationWarning" in proc.stderr + + +def test_warning_is_exported_and_is_a_deprecation_warning(): + assert "KyberDeprecationWarning" in pykyber.__all__ + assert issubclass(pykyber.KyberDeprecationWarning, DeprecationWarning) + assert not issubclass(pykyber.KyberDeprecationWarning, pykyber.KyberError) + + +def call_entry_point(cls, entry_point, kp, res): + """Make one call to a Kyber entry point from this file.""" + if entry_point == "new": + return cls() + if entry_point == "from_keys": + return cls.from_keys(kp.public_key, kp.secret_key) + if entry_point == "encapsulate": + return cls.encapsulate(kp.public_key) + return cls.decapsulate(res.ciphertext, kp.secret_key) + + +@pytest.mark.parametrize("entry_point", ENTRY_POINTS) +@pytest.mark.parametrize("class_name", KYBER_CLASSES) +def test_warning_is_attributed_to_the_callers_file(class_name, entry_point, monkeypatch): + cls = getattr(pykyber, class_name) + monkeypatch.setattr(_deprecation, "_warned", True) # keep the setup quiet + kp = cls() + res = cls.encapsulate(kp.public_key) + monkeypatch.setattr(_deprecation, "_warned", False) + with pytest.warns(pykyber.KyberDeprecationWarning) as record: + call_entry_point(cls, entry_point, kp, res) + assert len(record) == 1 + assert record[0].filename == __file__ diff --git a/python/tests/test_legacy_vectors.py b/python/tests/test_legacy_vectors.py new file mode 100644 index 0000000..8c20510 --- /dev/null +++ b/python/tests/test_legacy_vectors.py @@ -0,0 +1,142 @@ +"""Golden-vector freeze for legacy Kyber: pykyber v1.0.5 must keep producing +byte-identical output. + +The vector file ``tests/vectors/legacy-kyber-v1.0.5.json`` is append-only, +captured from the pinned PyPI wheel and a git worktree at tag v1.0.5 by +``tests/vectors/generate.py``. If these tests fail, the bug is in current +code; the vectors are never regenerated or edited to pass. +""" + +import hashlib +import json +from pathlib import Path + +import pytest + +import pykyber + +REPO_ROOT = Path(__file__).resolve().parents[2] +VECTORS_FILE = REPO_ROOT / "tests" / "vectors" / "legacy-kyber-v1.0.5.json" + +GOLDEN = json.loads(VECTORS_FILE.read_text(encoding="ascii")) + +# Layout constants of the frozen format (round-3 Kyber, FIPS 202 SHA3-256). +SEED_BYTES = 64 # d || z +SEED_HALF_BYTES = SEED_BYTES // 2 +POLY_BYTES = 384 # one polynomial, 12 bits per coefficient +PUBLIC_SEED_BYTES = 32 # rho at the end of the public key +HASH_BYTES = 32 # H(ek) +SEEDS_PER_SET = 3 +PARAMETER_SETS = 3 + +CLASSES = { + "512": pykyber.Kyber512, + "768": pykyber.Kyber768, + "1024": pykyber.Kyber1024, +} + + +def _vector_id(vector): + return "kyber{}-seed{}".format(vector["parameter_set"], vector["key_seed_hex"][:8]) + + +@pytest.mark.parametrize("vector", GOLDEN["vectors"], ids=[_vector_id(v) for v in GOLDEN["vectors"]]) +def test_seeded_keygen_matches_v1_0_5(vector): + cls = CLASSES[vector["parameter_set"]] + key = cls(seed=bytes.fromhex(vector["key_seed_hex"])) + assert key.public_key == bytes.fromhex(vector["keypair_public_key_hex"]) + assert key.secret_key == bytes.fromhex(vector["keypair_secret_key_hex"]) + + +@pytest.mark.parametrize("vector", GOLDEN["vectors"], ids=[_vector_id(v) for v in GOLDEN["vectors"]]) +def test_decapsulate_matches_v1_0_5(vector): + cls = CLASSES[vector["parameter_set"]] + shared_secret = cls.decapsulate( + bytes.fromhex(vector["ciphertext_hex"]), + bytes.fromhex(vector["keypair_secret_key_hex"]), + ) + assert shared_secret == bytes.fromhex(vector["shared_secret_decaps_hex"]) + + +@pytest.mark.parametrize("vector", GOLDEN["vectors"], ids=[_vector_id(v) for v in GOLDEN["vectors"]]) +def test_from_keys_decapsulate_matches_v1_0_5(vector): + cls = CLASSES[vector["parameter_set"]] + key = cls.from_keys( + bytes.fromhex(vector["keypair_public_key_hex"]), + bytes.fromhex(vector["keypair_secret_key_hex"]), + ) + assert key.decapsulate(bytes.fromhex(vector["ciphertext_hex"])) == bytes.fromhex( + vector["shared_secret_decaps_hex"] + ) + + +@pytest.mark.parametrize("vector", GOLDEN["vectors"], ids=[_vector_id(v) for v in GOLDEN["vectors"]]) +def test_encapsulate_against_v1_0_5_keys_roundtrips(vector): + """Current encapsulate must stay compatible with 1.0.5 key material.""" + cls = CLASSES[vector["parameter_set"]] + result = cls.encapsulate(bytes.fromhex(vector["keypair_public_key_hex"])) + shared_secret = cls.decapsulate( + result.ciphertext, bytes.fromhex(vector["keypair_secret_key_hex"]) + ) + assert shared_secret == result.shared_secret + + +def test_vector_shapes_match_class_sizes(): + for parameter_set, cls in CLASSES.items(): + vector = next(v for v in GOLDEN["vectors"] if v["parameter_set"] == parameter_set) + assert len(bytes.fromhex(vector["keypair_public_key_hex"])) == cls.PUBLIC_KEY_SIZE + assert len(bytes.fromhex(vector["keypair_secret_key_hex"])) == cls.SECRET_KEY_SIZE + assert len(bytes.fromhex(vector["ciphertext_hex"])) == cls.CIPHERTEXT_SIZE + assert len(bytes.fromhex(vector["shared_secret_decaps_hex"])) == cls.SHARED_SECRET_SIZE + + +def test_vector_file_internals_depend_only_on_the_file(): + """Re-verify the vector file's own layout invariants on every machine, + independently of the Rust library and of any replay outcome. + + Round-3 Kyber keys are written as sk = s || ek || H(ek) || z (the layout + frozen by pykyber v1.0.5; cf. src/kyber/kem.rs, hash_sha3_256 over the + encoded public key), and the encapsulation shared secret must equal the + decapsulation one. If the file is accidentally or deliberately edited, the + replay tests can only fail when the current code produces different bytes; + these checks fail additionally when the stored bytes are not internally + coherent at all.""" + + # Provenance preamble: file declares append-only and its capture basis. + meta = GOLDEN["meta"] + assert meta["append_only"] is True + assert meta["generator"] == "tests/vectors/generate.py" + assert meta["pypi_package"] == "pykyber==1.0.5" + assert ( + meta["pypi_wheel_sha256"] + == "0bd5d644e7783f4cf3bfffaa04fa42f96f43a68b6954e5927bc8bd94a2f2592a" + ) + assert meta["rust_tag"] == "v1.0.5" + assert len(GOLDEN["vectors"]) == SEEDS_PER_SET * PARAMETER_SETS + + seen_public_keys = set() + for vector in GOLDEN["vectors"]: + ps = vector["parameter_set"] + cls = CLASSES[ps] + seed = bytes.fromhex(vector["key_seed_hex"]) + pk = bytes.fromhex(vector["keypair_public_key_hex"]) + sk = bytes.fromhex(vector["keypair_secret_key_hex"]) + ct = bytes.fromhex(vector["ciphertext_hex"]) + ss_encaps = bytes.fromhex(vector["shared_secret_encaps_hex"]) + ss_decaps = bytes.fromhex(vector["shared_secret_decaps_hex"]) + + assert len(seed) == SEED_BYTES, ps # d || z + assert len(pk) == cls.PUBLIC_KEY_SIZE, ps + assert len(sk) == cls.SECRET_KEY_SIZE, ps + assert len(ct) == cls.CIPHERTEXT_SIZE, ps + assert len(ss_encaps) == len(ss_decaps) == cls.SHARED_SECRET_SIZE, ps + assert ss_encaps == ss_decaps, ps + assert pk not in seen_public_keys, "duplicate public key in the vector file" + seen_public_keys.add(pk) + + k = (len(pk) - PUBLIC_SEED_BYTES) // POLY_BYTES # pk is 384*k + 32 bytes + offset = POLY_BYTES * k + assert sk[offset : offset + len(pk)] == pk, ps # sk embeds ek verbatim + hash_field = sk[offset + len(pk) : offset + len(pk) + HASH_BYTES] + assert hash_field == hashlib.sha3_256(pk).digest(), ps # sk embeds H(ek) + assert sk[offset + len(pk) + HASH_BYTES :] == seed[SEED_HALF_BYTES:], ps # sk z-tail == seed z diff --git a/python/tests/test_mlkem.py b/python/tests/test_mlkem.py new file mode 100644 index 0000000..bb68e51 --- /dev/null +++ b/python/tests/test_mlkem.py @@ -0,0 +1,261 @@ +"""The public ML-KEM classes: round trips, validation, seeds, and the refusal +to mix Kyber and ML-KEM objects. Official vectors are in test_mlkem_acvp.py.""" + +import base64 + +import pytest + +import pykyber +from pykyber import ( + AlgorithmMismatchError, + InvalidDecapsulationKeyError, + InvalidEncapsulationKeyError, + InvalidInputError, + KyberError, +) + +# FIPS 203 Table 3 and section 3.3, as expected values (ek, dk, c). +SIZES = { + "ML-KEM-512": (800, 1632, 768), + "ML-KEM-768": (1184, 2400, 1088), + "ML-KEM-1024": (1568, 3168, 1568), +} +SHARED_SECRET_BYTES = 32 +SEED_HALF_BYTES = 32 # d and z +SEED_BYTES = 2 * SEED_HALF_BYTES +HASH_BYTES = 32 # H(ek) inside dk + +PAIRS = [ + (pykyber.ML_KEM_512, pykyber.Kyber512), + (pykyber.ML_KEM_768, pykyber.Kyber768), + (pykyber.ML_KEM_1024, pykyber.Kyber1024), +] +CLASSES = [ml for ml, _ in PAIRS] +PAIR_IDS = [ml.ALGORITHM for ml in CLASSES] + +SEED = bytes(range(SEED_BYTES)) + + +@pytest.fixture(params=CLASSES, ids=PAIR_IDS) +def cls(request): + return request.param + + +@pytest.fixture(params=PAIRS, ids=PAIR_IDS) +def pair(request): + return request.param + + +def test_sizes(cls): + assert (cls.PUBLIC_KEY_SIZE, cls.SECRET_KEY_SIZE, cls.CIPHERTEXT_SIZE) == SIZES[cls.ALGORITHM] + assert cls.SHARED_SECRET_SIZE == SHARED_SECRET_BYTES + assert cls.SEED_SIZE == SEED_BYTES + + +def test_keypair_round_trip(cls): + key = cls() + ek, dk, ct_size = SIZES[cls.ALGORITHM] + assert (len(key.public_key), len(key.secret_key)) == (ek, dk) + result = key.encapsulate() + assert len(result.ciphertext) == ct_size + assert len(result.shared_secret) == SHARED_SECRET_BYTES + assert key.decapsulate(result.ciphertext) == result.shared_secret + assert key.decapsulate(result) == result.shared_secret + + +def test_static_methods_and_from_keys(cls): + key = cls() + result = cls.encapsulate(key.public_key) + assert cls.decapsulate(result.ciphertext, key.secret_key) == result.shared_secret + rebuilt = cls.from_keys(key.public_key, key.secret_key) + assert rebuilt.decapsulate(result.ciphertext) == result.shared_secret + assert rebuilt.seed is None + # An ML-KEM object of the same set can stand in for its bytes. + assert cls.decapsulate(result, key) == result.shared_secret + assert cls.from_keys(key, key).public_key == key.public_key + + +def test_encapsulations_differ(cls): + key = cls() + first, second = key.encapsulate(), key.encapsulate() + assert first.ciphertext != second.ciphertext + assert first.shared_secret != second.shared_secret + + +def test_tuple_unpacking_and_encodings(cls): + key = cls() + public_key, secret_key = key + assert (public_key, secret_key) == (key.public_key, key.secret_key) + result = key.encapsulate() + ciphertext, shared_secret = result + assert (ciphertext, shared_secret) == (result.ciphertext, result.shared_secret) + + assert key.public_key_hex == public_key.hex() and key.secret_key_hex == secret_key.hex() + assert key.public_key_b64 == base64.b64encode(public_key).decode() + assert key.secret_key_b64 == base64.b64encode(secret_key).decode() + assert key.to_dict() == {"public_key": public_key.hex(), "secret_key": secret_key.hex()} + assert result.ciphertext_hex == ciphertext.hex() and result.shared_secret_hex == shared_secret.hex() + assert result.ciphertext_b64 == base64.b64encode(ciphertext).decode() + assert result.shared_secret_b64 == base64.b64encode(shared_secret).decode() + assert result.to_dict() == {"ciphertext": ciphertext.hex(), "shared_secret": shared_secret.hex()} + + +def test_repr_shows_no_secret(cls): + key = cls(SEED) + result = key.encapsulate() + for text in (repr(key), repr(result)): + assert cls.ALGORITHM in text + for secret in (key.secret_key, SEED, result.shared_secret): + assert secret.hex() not in text + + +def test_modified_ciphertext_gives_implicit_rejection_secret(cls): + key = cls() + result = key.encapsulate() + bad = bytearray(result.ciphertext) + bad[0] ^= 1 + rejected = key.decapsulate(bytes(bad)) + assert len(rejected) == SHARED_SECRET_BYTES + assert rejected != result.shared_secret + assert key.decapsulate(bytes(bad)) == rejected # deterministic for this key + + +def test_seed_is_deterministic_and_exposed(cls): + first, second = cls(SEED), cls(SEED) + assert (first.public_key, first.secret_key) == (second.public_key, second.secret_key) + assert first.seed == SEED + assert cls(bytes(SEED_BYTES)).public_key != first.public_key + # dk ends with z, the second half of the seed (FIPS 203 Algorithm 16). + assert first.secret_key.endswith(SEED[SEED_HALF_BYTES:]) + # d then z: swapping the halves is a different key. + swapped = SEED[SEED_HALF_BYTES:] + SEED[:SEED_HALF_BYTES] + assert cls(swapped).public_key != first.public_key + + +def test_random_keypair_has_no_seed(cls): + assert cls().seed is None + assert cls().public_key != cls().public_key + assert "seed" not in cls(SEED).to_dict() + + +def test_same_seed_gives_different_keys_than_kyber(pair): + ml_kem, kyber = pair + assert ml_kem(SEED).public_key != kyber(SEED).public_key + + +@pytest.mark.parametrize("length", [0, SEED_BYTES - 1, SEED_BYTES + 1]) +def test_bad_seed_length(cls, length): + with pytest.raises(InvalidInputError, match="seed"): + cls(bytes(length)) + + +@pytest.mark.parametrize("seed", ["x" * SEED_BYTES, bytearray(SEED_BYTES), 7]) +def test_bad_seed_type(cls, seed): + with pytest.raises(InvalidInputError, match="seed"): + cls(seed) + + +def test_bad_public_key_and_secret_key_and_ciphertext(cls): + key = cls() + result = key.encapsulate() + for bad in (key.public_key[:-1], key.public_key + b"\0", b"", None, "text", bytearray(key.public_key), 5): + with pytest.raises(InvalidInputError, match="public_key"): + cls.encapsulate(bad) + with pytest.raises(InvalidInputError, match="public_key"): + cls.from_keys(bad, key.secret_key) + for bad in (key.secret_key[:-1], key.secret_key + b"\0", b"", None, "text", bytearray(key.secret_key)): + with pytest.raises(InvalidInputError, match="secret_key"): + cls.decapsulate(result.ciphertext, bad) + with pytest.raises(InvalidInputError, match="secret_key"): + cls.from_keys(key.public_key, bad) + for bad in (result.ciphertext[:-1], result.ciphertext + b"\0", b"", None, "text"): + with pytest.raises(InvalidInputError, match="ciphertext"): + cls.decapsulate(bad, key.secret_key) + with pytest.raises(InvalidInputError, match="ciphertext"): + key.decapsulate(bad) + + +def test_validation_errors_are_kyber_errors(cls): + with pytest.raises(KyberError): + cls.encapsulate(b"") + assert issubclass(InvalidInputError, KyberError) + + +def test_ek_that_fails_the_modulus_check(cls): + # Section 7.2: every 12-bit coefficient 4095 is >= q. + with pytest.raises(InvalidEncapsulationKeyError) as info: + cls.encapsulate(b"\xff" * cls.PUBLIC_KEY_SIZE) + assert isinstance(info.value, KyberError) + + +def test_dk_that_fails_the_hash_check(cls): + key = cls() + result = key.encapsulate() + broken = bytearray(key.secret_key) + broken[cls.SECRET_KEY_SIZE - SEED_HALF_BYTES - HASH_BYTES] ^= 1 # first byte of H(ek) + with pytest.raises(InvalidDecapsulationKeyError) as info: + cls.decapsulate(result.ciphertext, bytes(broken)) + assert isinstance(info.value, KyberError) + + +def test_refuses_kyber_objects(pair): + ml_kem, kyber = pair + kyber_key = kyber() + kyber_result = kyber_key.encapsulate() + ml_key = ml_kem() + ml_result = ml_key.encapsulate() + + with pytest.raises(AlgorithmMismatchError): + ml_kem.decapsulate(ml_result.ciphertext, kyber_key) + with pytest.raises(AlgorithmMismatchError): + ml_kem.decapsulate(kyber_result, ml_key.secret_key) + with pytest.raises(AlgorithmMismatchError): + ml_kem.encapsulate(kyber_key) + with pytest.raises(AlgorithmMismatchError): + ml_kem.from_keys(kyber_key, ml_key.secret_key) + with pytest.raises(AlgorithmMismatchError): + ml_kem.from_keys(ml_key.public_key, kyber_key) + with pytest.raises(AlgorithmMismatchError): + ml_key.decapsulate(kyber_result) + + error = AlgorithmMismatchError + assert issubclass(error, KyberError) and issubclass(error, TypeError) + + +def test_refuses_other_parameter_sets(): + key_768 = pykyber.ML_KEM_768() + result_768 = key_768.encapsulate() + for other in (pykyber.ML_KEM_512, pykyber.ML_KEM_1024): + with pytest.raises(AlgorithmMismatchError, match="ML-KEM-768"): + other.encapsulate(key_768) + with pytest.raises(AlgorithmMismatchError): + other.decapsulate(result_768, other()) + with pytest.raises(AlgorithmMismatchError): + other.decapsulate(other().encapsulate().ciphertext, key_768) + + +def test_kyber_methods_do_not_accept_ml_kem_objects(pair): + ml_kem, kyber = pair + ml_key = ml_kem() + ml_result = ml_key.encapsulate() + kyber_key = kyber() + refusals = (TypeError, KyberError) + with pytest.raises(refusals): + kyber.encapsulate(ml_key) + with pytest.raises(refusals): + kyber.decapsulate(ml_result, kyber_key.secret_key) + with pytest.raises(refusals): + kyber.decapsulate(kyber_key.encapsulate().ciphertext, ml_key) + with pytest.raises(refusals): + kyber.from_keys(ml_key, kyber_key.secret_key) + + +def test_raw_bytes_cannot_be_told_apart(pair): + # The documented limit: Kyber bytes are accepted by ML-KEM (same sizes) and + # give a different secret, which is why the objects carry their algorithm. + ml_kem, kyber = pair + kyber_key = kyber() + kyber_result = kyber_key.encapsulate() + mixed = ml_kem.decapsulate(kyber_result.ciphertext, kyber_key.secret_key) + assert len(mixed) == SHARED_SECRET_BYTES + assert mixed != kyber_result.shared_secret diff --git a/python/tests/test_mlkem_acvp.py b/python/tests/test_mlkem_acvp.py new file mode 100644 index 0000000..96c4fb8 --- /dev/null +++ b/python/tests/test_mlkem_acvp.py @@ -0,0 +1,122 @@ +"""NIST ACVP ML-KEM vectors (ACVP-Server, internalProjection files) through the +public Python API. + +The files are fetched by ``scripts/fetch_vectors.py`` into +``tests/vectors/external`` and never committed; a missing file fails the run. + +- keyGen: the seed ``d || z`` gives exactly the vector's ``ek`` and ``dk``. +- decapsulation: ``dk`` passes the hash check, so the public decapsulate must + return the vector's ``k`` (also for the "modified ciphertext" cases, which + pin the implicit-rejection secret). +- encapsulation: the vector fixes ``m``, which the public API does not allow, + so the check is that the output decapsulates, with the paired ``dk``, to the + same secret. +- key checks: the modulus check (section 7.2) and the hash check (section 7.3) + through ``encapsulate`` and ``decapsulate``. + +The ``FIPS203-tr1`` encapDecap revision (a new test revision against the same +standard) adds more cases for the same functions, plus decapsulation groups +whose key is the seed ``d || z`` (``keyFormat`` "seed"). +""" + +import json +from pathlib import Path + +import pytest + +import pykyber + +ACVP_DIR = Path(__file__).resolve().parents[2] / "tests" / "vectors" / "external" / "acvp" +KEYGEN_FILE = ACVP_DIR / "ML-KEM-keyGen-FIPS203" / "internalProjection.json" +ENCAP_DECAP_FILE = ACVP_DIR / "ML-KEM-encapDecap-FIPS203" / "internalProjection.json" +TR1_FILE = ACVP_DIR / "ML-KEM-encapDecap-FIPS203-tr1" / "internalProjection.json" + +CLASSES = { + "ML-KEM-512": pykyber.ML_KEM_512, + "ML-KEM-768": pykyber.ML_KEM_768, + "ML-KEM-1024": pykyber.ML_KEM_1024, +} +SHARED_SECRET_BYTES = 32 + + +def _load(path): + if not path.exists(): + raise FileNotFoundError("%s is missing; run scripts/fetch_vectors.py" % path) + return json.loads(path.read_text(encoding="ascii"))["testGroups"] + + +def _cases(path, function=None, key_format=None): + """(id, class, test) for the groups of ``function`` (None for keyGen) and + ``key_format`` (None for any).""" + prefix = "tr1-" if path == TR1_FILE else "" + cases = [] + for group in _load(path): + if function is not None and group["function"] != function: + continue + if key_format is not None and group["keyFormat"] != key_format: + continue + cls = CLASSES[group["parameterSet"]] + for test in group["tests"]: + cases.append( + pytest.param(cls, test, id="%s%s-tc%d" % (prefix, cls.ALGORITHM, test["tcId"])) + ) + return cases + + +def _all_revisions(function, tr1_key_format=None): + """The original encapDecap cases of ``function`` plus the tr1 ones.""" + return _cases(ENCAP_DECAP_FILE, function) + _cases(TR1_FILE, function, tr1_key_format) + + +def _b(test, field): + return bytes.fromhex(test[field]) + + +@pytest.mark.parametrize("cls, test", _cases(KEYGEN_FILE)) +def test_keygen_from_seed(cls, test): + key = cls(seed=_b(test, "d") + _b(test, "z")) + assert key.public_key == _b(test, "ek") + assert key.secret_key == _b(test, "dk") + + +@pytest.mark.parametrize("cls, test", _all_revisions("decapsulation", "expanded")) +def test_decapsulation(cls, test): + assert cls.decapsulate(_b(test, "c"), _b(test, "dk")) == _b(test, "k") + assert cls.from_keys(_b(test, "ek"), _b(test, "dk")).decapsulate(_b(test, "c")) == _b(test, "k") + + +@pytest.mark.parametrize("cls, test", _cases(TR1_FILE, "decapsulation", "seed")) +def test_decapsulation_from_seed_key(cls, test): + key = cls(seed=_b(test, "d") + _b(test, "z")) + assert key.public_key == _b(test, "ek") + assert key.secret_key == _b(test, "dk") + assert key.decapsulate(_b(test, "c")) == _b(test, "k") + + +@pytest.mark.parametrize("cls, test", _all_revisions("encapsulation")) +def test_encapsulation_decapsulates_to_the_same_secret(cls, test): + result = cls.encapsulate(_b(test, "ek")) + assert len(result.ciphertext) == len(_b(test, "c")) + assert len(result.shared_secret) == SHARED_SECRET_BYTES + assert cls.decapsulate(result.ciphertext, _b(test, "dk")) == result.shared_secret + + +@pytest.mark.parametrize("cls, test", _all_revisions("encapsulationKeyCheck")) +def test_encapsulation_key_check(cls, test): + ek = _b(test, "ek") + if test["testPassed"]: + assert len(cls.encapsulate(ek).ciphertext) == cls.CIPHERTEXT_SIZE + else: + with pytest.raises(pykyber.InvalidEncapsulationKeyError): + cls.encapsulate(ek) + + +@pytest.mark.parametrize("cls, test", _all_revisions("decapsulationKeyCheck")) +def test_decapsulation_key_check(cls, test): + dk = _b(test, "dk") + ciphertext = bytes(cls.CIPHERTEXT_SIZE) # any well-formed ciphertext reaches the key check + if test["testPassed"]: + assert len(cls.decapsulate(ciphertext, dk)) == SHARED_SECRET_BYTES + else: + with pytest.raises(pykyber.InvalidDecapsulationKeyError): + cls.decapsulate(ciphertext, dk) diff --git a/python/tests/test_mlkem_bindings.py b/python/tests/test_mlkem_bindings.py new file mode 100644 index 0000000..38ec92a --- /dev/null +++ b/python/tests/test_mlkem_bindings.py @@ -0,0 +1,75 @@ +"""The private ML-KEM functions of ``pykyber._pykyber``: output shapes and the +exception each Rust error becomes. The public classes are tested in +``test_mlkem.py``.""" + +import pytest + +from pykyber import KyberError, _pykyber +from pykyber import _error + +# FIPS 203 Table 3 (ek, dk, c sizes) and section 3.3 (32-byte shared secret). +SETS = { + 512: (800, 1632, 768), + 768: (1184, 2400, 1088), + 1024: (1568, 3168, 1568), +} +SHARED_SECRET_BYTES = 32 +SEED_HALF_BYTES = 32 # d and z +SEED_BYTES = 2 * SEED_HALF_BYTES +HASH_BYTES = 32 # H(ek) inside dk + + +def _fns(level): + return tuple(getattr(_pykyber, "_mlkem_%d_%s" % (level, name)) for name in ("keygen", "encaps", "decaps")) + + +@pytest.mark.parametrize("level", SETS) +def test_round_trip_and_sizes(level): + keygen, encaps, decaps = _fns(level) + ek_size, dk_size, ct_size = SETS[level] + ek, dk = keygen() + assert (len(ek), len(dk)) == (ek_size, dk_size) + ct, ss = encaps(ek) + assert len(ct) == ct_size and len(ss) == SHARED_SECRET_BYTES + assert decaps(ct, dk) == ss + assert all(type(x) is bytes for x in (ek, dk, ct, ss)) + + +@pytest.mark.parametrize("level", SETS) +def test_seed_is_d_then_z(level): + keygen = _fns(level)[0] + d, z = bytes(range(SEED_HALF_BYTES)), bytes(range(SEED_HALF_BYTES, SEED_BYTES)) + ek, dk = keygen(d + z) + assert (ek, dk) == keygen(d + z) + assert dk.endswith(z) + assert keygen(z + d) != (ek, dk) + + +@pytest.mark.parametrize("level", SETS) +def test_errors_are_kyber_error_subclasses(level): + keygen, encaps, decaps = _fns(level) + ek_size, dk_size, ct_size = SETS[level] + ek, dk = keygen() + ct, _ = encaps(ek) + + with pytest.raises(_error.InvalidInputError) as info: + keygen(bytes(SEED_BYTES - 1)) + assert "seed" in str(info.value) + with pytest.raises(_error.InvalidInputError): + encaps(ek[:-1]) + with pytest.raises(_error.InvalidInputError): + decaps(ct[:-1], dk) + with pytest.raises(_error.InvalidInputError): + decaps(ct, dk[:-1]) + + # Section 7.2: a coefficient of ek equal to 2^12 - 1 is not reduced mod q. + with pytest.raises(_error.InvalidEncapsulationKeyError): + encaps(b"\xff" * ek_size) + # Section 7.3: the stored H(ek) no longer matches. + bad = bytearray(dk) + bad[dk_size - SEED_HALF_BYTES - HASH_BYTES] ^= 1 # first byte of H(ek) + with pytest.raises(_error.InvalidDecapsulationKeyError): + decaps(ct, bytes(bad)) + + for cls in (_error.InvalidInputError, _error.InvalidEncapsulationKeyError, _error.InvalidDecapsulationKeyError): + assert issubclass(cls, KyberError) diff --git a/python/tests/test_mlkem_cctv.py b/python/tests/test_mlkem_cctv.py new file mode 100644 index 0000000..2966aab --- /dev/null +++ b/python/tests/test_mlkem_cctv.py @@ -0,0 +1,89 @@ +"""C2SP CCTV ML-KEM vectors (repository C2SP/CCTV, directory ML-KEM) through +the public Python API. + +The files are fetched by ``scripts/fetch_vectors.py`` into +``tests/vectors/external`` and never committed; a missing file fails the run. + +- modulus: encapsulation keys with a coefficient in q..2**12-1 must be refused + by ``encapsulate`` (FIPS 203 section 7.2), at every position and value. +- unluckysample: a key pair whose ``rho`` needs more than 575 XOF bytes in + SampleNTT; ``encapsulate`` and ``decapsulate`` must work on it. +- strcmp: a modified ciphertext that a ``strcmp`` based comparison would accept; + ``decapsulate`` must return the implicit-rejection secret. + +The unluckysample files were made before the final standard added ``k`` to the +key generation hash (``rho`` is ``G(d)`` there), so their ``d`` cannot be fed to +``ML_KEM_*(seed=...)``. The accumulated vectors and the exact ``m`` of +encapsulation need internal functions and are checked in Rust +(``src/mlkem/cctv_tests.rs``). +""" + +import gzip +from pathlib import Path + +import pytest + +import pykyber + +CCTV_DIR = Path(__file__).resolve().parents[2] / "tests" / "vectors" / "external" / "cctv" +CLASSES = (pykyber.ML_KEM_512, pykyber.ML_KEM_768, pykyber.ML_KEM_1024) +SHARED_SECRET_BYTES = 32 +N = 256 # coefficients per polynomial +Q = 3329 +COEFFICIENT_BITS = 12 +BITS_PER_BYTE = 8 +POLY_BYTES = N * COEFFICIENT_BITS // BITS_PER_BYTE +# modulus.go: four edge keys per polynomial, then a sweep that runs until every +# value in q..2**12-1 and every position has been used. +EDGE_KEYS_PER_POLY = 4 +OVERFLOW_VALUES = (1 << COEFFICIENT_BITS) - Q + + +def _read(relative): + path = CCTV_DIR / relative + if not path.exists(): + raise FileNotFoundError("%s is missing; run scripts/fetch_vectors.py" % path) + return path.read_bytes() + + +def _record(relative): + """The ``name = hex`` fields of a one-record file.""" + fields = {} + for line in _read(relative).decode("ascii").splitlines(): + name, _, value = line.partition(" = ") + assert name not in fields, "%s repeats %s" % (relative, name) + fields[name] = bytes.fromhex(value) + return fields + + +def _polynomials(cls): + """k, from ``ek = k * 384 + 32`` bytes (ByteEncode12 of t, then rho).""" + return (cls.PUBLIC_KEY_SIZE - SHARED_SECRET_BYTES) // POLY_BYTES + + +@pytest.mark.parametrize("cls", CLASSES, ids=lambda cls: cls.ALGORITHM) +def test_modulus_check_negatives(cls): + lines = gzip.decompress(_read("modulus/%s.txt.gz" % cls.ALGORITHM)).decode("ascii").splitlines() + k = _polynomials(cls) + assert len(lines) == EDGE_KEYS_PER_POLY * k + max(OVERFLOW_VALUES, k * N) + for line in lines: + ek = bytes.fromhex(line) + assert len(ek) == cls.PUBLIC_KEY_SIZE + with pytest.raises(pykyber.InvalidEncapsulationKeyError): + cls.encapsulate(ek) + + +@pytest.mark.parametrize("cls", CLASSES, ids=lambda cls: cls.ALGORITHM) +def test_unlucky_sampling_key_pair(cls): + fields = _record("unluckysample/%s.txt" % cls.ALGORITHM) + result = cls.encapsulate(fields["ek"]) + assert cls.decapsulate(result.ciphertext, fields["dk"]) == result.shared_secret + assert cls.decapsulate(fields["c"], fields["dk"]) == fields["K"] + assert cls.from_keys(fields["ek"], fields["dk"]).decapsulate(fields["c"]) == fields["K"] + + +@pytest.mark.parametrize("cls", CLASSES, ids=lambda cls: cls.ALGORITHM) +def test_strcmp_ciphertext_is_rejected_implicitly(cls): + fields = _record("strcmp/%s.txt" % cls.ALGORITHM) + assert len(fields["K"]) == SHARED_SECRET_BYTES + assert cls.decapsulate(fields["c"], fields["dk"]) == fields["K"] diff --git a/python/tests/test_mlkem_wycheproof.py b/python/tests/test_mlkem_wycheproof.py new file mode 100644 index 0000000..05bb5a4 --- /dev/null +++ b/python/tests/test_mlkem_wycheproof.py @@ -0,0 +1,109 @@ +"""C2SP Wycheproof ML-KEM vectors (repository C2SP/wycheproof, directory +``testvectors_v1``) through the public Python API. + +The files are fetched by ``scripts/fetch_vectors.py`` into +``tests/vectors/external`` and never committed; a missing file fails the run. + +``result`` follows ``schemas/common.json`` of the pinned commit: ``valid`` +must succeed with the given output, ``invalid`` must raise ``KyberError``, and +``acceptable`` may do either but must give the output if it succeeds. The +flags only name the bug a case targets. The pinned files have no +``acceptable`` case; the rule is here so a pin bump cannot slip one through. + +- ``mlkem_N_test.json``: a seed that is not 64 bytes, or a ciphertext of the + wrong length, is ``invalid``; otherwise the key from the seed decapsulates + ``c`` to ``K``. +- ``mlkem_N_encaps_test.json``: the public API draws ``m`` itself, so a + ``valid`` case checks that ``encapsulate`` accepts ``ek`` and returns sizes + that fit; the exact ``c`` and ``K`` for the given ``m`` are checked in Rust + (``src/mlkem/wycheproof_tests.rs``). +- ``mlkem_N_keygen_seed_test.json``: the seed gives exactly ``ek`` and ``dk``. +- ``mlkem_N_semi_expanded_decaps_test.json``: ``decapsulate`` with the given + ``dk``. +""" + +import json +from pathlib import Path + +import pytest + +import pykyber + +WYCHEPROOF_DIR = Path(__file__).resolve().parents[2] / "tests" / "vectors" / "external" / "wycheproof" +CLASSES = { + "ML-KEM-512": pykyber.ML_KEM_512, + "ML-KEM-768": pykyber.ML_KEM_768, + "ML-KEM-1024": pykyber.ML_KEM_1024, +} +SHARED_SECRET_BYTES = 32 + + +def _cases(kind, schema): + """(class, test) for every case of the ``kind`` file of each parameter set.""" + cases = [] + for name, cls in CLASSES.items(): + path = WYCHEPROOF_DIR / ("mlkem_%s_%s.json" % (name.rsplit("-", 1)[1], kind)) + if not path.exists(): + raise FileNotFoundError("%s is missing; run scripts/fetch_vectors.py" % path) + doc = json.loads(path.read_text(encoding="utf-8")) + assert doc["algorithm"] == "ML-KEM" and doc["schema"] == schema, path + tests = [t for group in doc["testGroups"] for t in group["tests"]] + assert len(tests) == doc["numberOfTests"], path + assert all(g["parameterSet"] == name for g in doc["testGroups"]), path + cases += [pytest.param(cls, t, id="%s-tc%d" % (name, t["tcId"])) for t in tests] + return cases + + +def _b(test, field): + return bytes.fromhex(test[field]) + + +def _judge(test, run, expected): + """Apply the ``result`` rule to ``run()``; ``expected`` is the output of a valid case.""" + result = test["result"] + assert result in ("valid", "invalid", "acceptable"), test["tcId"] + try: + output = run() + except pykyber.KyberError: + assert result != "valid", "tcId %d: valid case refused" % test["tcId"] + return + assert result != "invalid", "tcId %d: invalid case accepted" % test["tcId"] + if result == "valid" or expected: + assert output == expected, "tcId %d" % test["tcId"] + + +@pytest.mark.parametrize("cls, test", _cases("test", "mlkem_test_schema.json")) +def test_decapsulation_from_seed(cls, test): + def run(): + key = cls(seed=_b(test, "seed")) + assert key.public_key == _b(test, "ek") + return key.decapsulate(_b(test, "c")) + + _judge(test, run, _b(test, "K")) + + +@pytest.mark.parametrize("cls, test", _cases("encaps_test", "mlkem_encaps_test_schema.json")) +def test_encapsulation_key_check(cls, test): + def run(): + result = cls.encapsulate(_b(test, "ek")) + assert len(result.ciphertext) == cls.CIPHERTEXT_SIZE + assert len(result.shared_secret) == SHARED_SECRET_BYTES + return b"" + + _judge(test, run, b"") + + +@pytest.mark.parametrize("cls, test", _cases("keygen_seed_test", "mlkem_keygen_seed_test_schema.json")) +def test_key_generation_from_seed(cls, test): + assert test["result"] == "valid" + key = cls(seed=_b(test, "seed")) + assert key.public_key == _b(test, "ek") + assert key.secret_key == _b(test, "dk") + + +@pytest.mark.parametrize( + "cls, test", _cases("semi_expanded_decaps_test", "mlkem_semi_expanded_decaps_test_schema.json") +) +def test_decapsulation_with_a_given_key(cls, test): + expected = _b(test, "K") if "K" in test else b"" + _judge(test, lambda: cls.decapsulate(_b(test, "c"), _b(test, "dk")), expected) diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..55dc92c --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +# Development and CI tooling only. The extension has no runtime dependencies. +# pytest 8.x is the last line that still supports Python 3.8. +maturin>=1.12,<2.0 +pytest>=7,<9 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 new file mode 100755 index 0000000..7be5a74 --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,15 @@ +#!/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. +# 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 +# older commits too, so skip it where the script does not exist yet. +if [ -f scripts/fetch_vectors.py ]; then python scripts/fetch_vectors.py; fi + +cargo fmt --check +cargo clippy --all-targets -- -D warnings +cargo test --locked --no-fail-fast +maturin develop +python -m pytest python/tests diff --git a/scripts/fetch_vectors.py b/scripts/fetch_vectors.py new file mode 100755 index 0000000..66284a3 --- /dev/null +++ b/scripts/fetch_vectors.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Download the external test vectors named in tests/vectors/manifest.json. + +External vectors are never committed. Each file is fetched from the pinned +commit of its upstream repository into tests/vectors/external// (which +is gitignored) and checked against the SHA-256 in the manifest. A file that is +already present and matches is left alone; a file that does not match is +never written and the script fails, so a changed upstream file or a changed +pin is never used silently. Standard library only. +""" +import hashlib +import json +import sys +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +MANIFEST = ROOT / "tests" / "vectors" / "manifest.json" +EXTERNAL = ROOT / "tests" / "vectors" / "external" +RAW_URL = "https://raw.githubusercontent.com/{repository}/{commit}/{directory}/{path}" +TIMEOUT_SECONDS = 60 + + +def sha256(data): + return hashlib.sha256(data).hexdigest() + + +def fetch(name, source): + failures = 0 + for path, expected in source["files"].items(): + target = EXTERNAL / name / path + if target.exists() and sha256(target.read_bytes()) == expected: + continue + url = RAW_URL.format(path=path, **source) + print("fetching " + url) + with urllib.request.urlopen(url, timeout=TIMEOUT_SECONDS) as response: + data = response.read() + actual = sha256(data) + if actual != expected: + print("SHA-256 mismatch for %s: expected %s, got %s" % (url, expected, actual)) + failures += 1 + continue + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + return failures + + +def main(): + manifest = json.loads(MANIFEST.read_text()) + failures = sum(fetch(name, source) for name, source in manifest.items()) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/kyber/api.rs b/src/kyber/api.rs index 2cc0997..ad8b5c9 100644 --- a/src/kyber/api.rs +++ b/src/kyber/api.rs @@ -65,6 +65,9 @@ impl Keypair { } } +/// Stands in for a generator on the seeded path, which draws no randomness. The +/// KEM code asks only through `try_fill_bytes`, so if that ever changed it would +/// get a `RandomBytesGeneration` error and not a panic. struct DummyRng {} impl CryptoRng for DummyRng {} impl RngCore for DummyRng { @@ -75,7 +78,7 @@ impl RngCore for DummyRng { panic!() } fn try_fill_bytes(&mut self, _dest: &mut [u8]) -> Result<(), rand_core::Error> { - panic!() + Err(rand_core::Error::new("the seeded path draws no randomness")) } fn fill_bytes(&mut self, _dest: &mut [u8]) { panic!() diff --git a/src/kyber/error.rs b/src/kyber/error.rs index 2dba3e8..c79d8b2 100644 --- a/src/kyber/error.rs +++ b/src/kyber/error.rs @@ -7,6 +7,10 @@ pub enum KyberError { }, Decapsulation, RandomBytesGeneration, + /// ML-KEM encapsulation key failed the modulus check of FIPS 203 section 7.2. + InvalidEncapsulationKey, + /// ML-KEM decapsulation key failed the hash check of FIPS 203 section 7.3. + InvalidDecapsulationKey, } impl KyberError { @@ -38,6 +42,14 @@ impl core::fmt::Display for KyberError { "Decapsulation Failure: unable to obtain shared secret from ciphertext" ), KyberError::RandomBytesGeneration => write!(f, "Random bytes generation failed"), + KyberError::InvalidEncapsulationKey => write!( + f, + "Invalid encapsulation key: coefficients are not reduced modulo q" + ), + KyberError::InvalidDecapsulationKey => write!( + f, + "Invalid decapsulation key: the stored hash of the encapsulation key does not match" + ), } } } diff --git a/src/kyber/fips202.rs b/src/kyber/fips202.rs index f064ce0..ae137c1 100644 --- a/src/kyber/fips202.rs +++ b/src/kyber/fips202.rs @@ -15,6 +15,12 @@ pub struct KeccakState { pub pos: usize, } +impl Default for KeccakState { + fn default() -> Self { + Self::new() + } +} + impl KeccakState { pub fn new() -> Self { KeccakState { @@ -32,7 +38,7 @@ impl KeccakState { /// Rotate left operation for 64-bit values. /// Rotates `value` left by `offset` bits. fn rotate_left(value: u64, offset: u64) -> u64 { - (value << offset) | (value >> (64 - offset)) + value.rotate_left(offset as u32) } /// Load 8 bytes from little-endian byte array into a 64-bit unsigned integer. @@ -355,6 +361,7 @@ pub fn sha3_256(h: &mut [u8], input: &[u8], inlen: usize) { for i in 0..4 { store_u64(&mut h[8 * i..], s[i]); } + s.zeroize(); } pub fn sha3_512(h: &mut [u8], input: &[u8], inlen: usize) { @@ -364,10 +371,11 @@ pub fn sha3_512(h: &mut [u8], input: &[u8], inlen: usize) { for i in 0..8 { store_u64(&mut h[8 * i..], s[i]); } + s.zeroize(); } fn keccak_finalize(s: &mut [u64], pos: usize, r: usize, p: u8) { - s[pos / 8] ^= (p as u64) << 8 * (pos % 8); + s[pos / 8] ^= (p as u64) << (8 * (pos % 8)); s[r / 8 - 1] ^= 1u64 << 63; } @@ -385,9 +393,9 @@ pub fn keccak_absorb_once(s: &mut [u64], r: usize, input: &[u8], mut inlen: usiz } for i in 0..inlen { - s[i / 8] ^= (input[idx + i] as u64) << 8 * (i % 8); + s[i / 8] ^= (input[idx + i] as u64) << (8 * (i % 8)); } - s[inlen / 8] ^= (p as u64) << 8 * (inlen % 8); + s[inlen / 8] ^= (p as u64) << (8 * (inlen % 8)); s[(r - 1) / 8] ^= 1u64 << 63; } @@ -406,7 +414,10 @@ fn keccak_squeeze( } let mut i = pos; while i < r && outlen > 0 { - if outlen >= 8 && i % 8 == 0 && i + 8 <= r { + // `is_multiple_of` needs Rust 1.89; no MSRV is declared, so keep `%`. + #[allow(clippy::manual_is_multiple_of)] + let aligned = i % 8 == 0; + if outlen >= 8 && aligned && i + 8 <= r { store_u64(&mut out[idx..], s[i / 8]); idx += 8; outlen -= 8; diff --git a/src/kyber/indcpa.rs b/src/kyber/indcpa.rs index be3748a..3de0494 100644 --- a/src/kyber/indcpa.rs +++ b/src/kyber/indcpa.rs @@ -1,3 +1,5 @@ +// Matrix and index loops follow the reference C code; see poly.rs. +#![allow(clippy::needless_range_loop, clippy::identity_op)] use super::error::KyberError; use super::params::*; use super::poly::*; @@ -6,7 +8,7 @@ use super::symmetric::{ hash_sha3_512, xof_absorb_bytes, xof_squeeze_blocks, XofState, XOF_BLOCKBYTES, }; use rand_core::{CryptoRng, RngCore}; -use zeroize::Zeroize; +use zeroize::{Zeroize, Zeroizing}; fn pack_pk(r: &mut [u8], pk: &mut Polyvec, seed: &[u8]) { const END: usize = KYBER_SYMBYTES + KYBER_POLYVECBYTES; @@ -38,7 +40,7 @@ fn unpack_ciphertext(b: &mut Polyvec, v: &mut Poly, c: &[u8]) { poly_decompress(v, &c[KYBER_POLYVECCOMPRESSEDBYTES..]); } -fn rej_uniform(r: &mut [i16], len: usize, buf: &[u8], buflen: usize) -> usize { +pub(crate) fn rej_uniform(r: &mut [i16], len: usize, buf: &[u8], buflen: usize) -> usize { let (mut ctr, mut pos) = (0usize, 0usize); let (mut val0, mut val1); @@ -113,15 +115,16 @@ where let (mut e, mut pkpv, mut skpv) = (Polyvec::new(), Polyvec::new(), Polyvec::new()); let mut nonce = 0u8; let mut buf = [0u8; 2 * KYBER_SYMBYTES]; - let mut randbuf = [0u8; 2 * KYBER_SYMBYTES]; + let mut randbuf = Zeroizing::new([0u8; 2 * KYBER_SYMBYTES]); if let Some(s) = _seed { - randbuf[..KYBER_SYMBYTES].copy_from_slice(&s.0); + randbuf[..KYBER_SYMBYTES].copy_from_slice(s.0); } else { - _rng.fill_bytes(&mut randbuf[..KYBER_SYMBYTES]); + _rng.try_fill_bytes(&mut randbuf[..KYBER_SYMBYTES]) + .map_err(|_| KyberError::RandomBytesGeneration)?; } - hash_sha3_512(&mut buf, &randbuf, KYBER_SYMBYTES); + hash_sha3_512(&mut buf, &randbuf[..], KYBER_SYMBYTES); let (publicseed, noiseseed) = buf.split_at(KYBER_SYMBYTES); gen_a(&mut a, publicseed); @@ -295,15 +298,16 @@ where let (mut e, mut pkpv, mut skpv) = (Polyvec512::new(), Polyvec512::new(), Polyvec512::new()); let mut nonce = 0u8; let mut buf = [0u8; 2 * KYBER_SYMBYTES]; - let mut randbuf = [0u8; 2 * KYBER_SYMBYTES]; + let mut randbuf = Zeroizing::new([0u8; 2 * KYBER_SYMBYTES]); if let Some(s) = _seed { - randbuf[..KYBER_SYMBYTES].copy_from_slice(&s.0); + randbuf[..KYBER_SYMBYTES].copy_from_slice(s.0); } else { - _rng.fill_bytes(&mut randbuf[..KYBER_SYMBYTES]); + _rng.try_fill_bytes(&mut randbuf[..KYBER_SYMBYTES]) + .map_err(|_| KyberError::RandomBytesGeneration)?; } - hash_sha3_512(&mut buf, &randbuf, KYBER_SYMBYTES); + hash_sha3_512(&mut buf, &randbuf[..], KYBER_SYMBYTES); let (publicseed, noiseseed) = buf.split_at(KYBER_SYMBYTES); gen_a_512(&mut a, publicseed); @@ -479,15 +483,16 @@ where let (mut e, mut pkpv, mut skpv) = (Polyvec1024::new(), Polyvec1024::new(), Polyvec1024::new()); let mut nonce = 0u8; let mut buf = [0u8; 2 * KYBER_SYMBYTES]; - let mut randbuf = [0u8; 2 * KYBER_SYMBYTES]; + let mut randbuf = Zeroizing::new([0u8; 2 * KYBER_SYMBYTES]); if let Some(s) = _seed { - randbuf[..KYBER_SYMBYTES].copy_from_slice(&s.0); + randbuf[..KYBER_SYMBYTES].copy_from_slice(s.0); } else { - _rng.fill_bytes(&mut randbuf[..KYBER_SYMBYTES]); + _rng.try_fill_bytes(&mut randbuf[..KYBER_SYMBYTES]) + .map_err(|_| KyberError::RandomBytesGeneration)?; } - hash_sha3_512(&mut buf, &randbuf, KYBER_SYMBYTES); + hash_sha3_512(&mut buf, &randbuf[..], KYBER_SYMBYTES); let (publicseed, noiseseed) = buf.split_at(KYBER_SYMBYTES); gen_a_1024(&mut a, publicseed); diff --git a/src/kyber/kem.rs b/src/kyber/kem.rs index cbcfcd2..fe8a3d5 100644 --- a/src/kyber/kem.rs +++ b/src/kyber/kem.rs @@ -7,7 +7,50 @@ use super::params::*; use super::symmetric::{hash_sha3_256, hash_sha3_512, shake256_kdf}; use super::verify::{cmov, verify}; use rand_core::{CryptoRng, RngCore}; -use zeroize::Zeroize; +use zeroize::{Zeroize, Zeroizing}; + +/// Fails with `InvalidInput` unless `actual == expected`; lengths are public. +fn check_len(param: &'static str, expected: usize, actual: usize) -> Result<(), KyberError> { + if actual != expected { + return Err(KyberError::invalid_input(param, expected, actual)); + } + Ok(()) +} + +/// Length checks shared by the three key generation functions. +fn check_keypair_args( + pk: &[u8], + sk: &[u8], + seed: Option<(&[u8], &[u8])>, + pk_len: usize, + sk_len: usize, +) -> Result<(), KyberError> { + check_len("pk", pk_len, pk.len())?; + check_len("sk", sk_len, sk.len())?; + if let Some((d, z)) = seed { + check_len("seed d", KYBER_SYMBYTES, d.len())?; + check_len("seed z", KYBER_SYMBYTES, z.len())?; + } + Ok(()) +} + +/// Length checks shared by the three encapsulation functions. +fn check_enc_args( + ct: &[u8], + ss: &[u8], + pk: &[u8], + seed: Option<&[u8]>, + ct_len: usize, + pk_len: usize, +) -> Result<(), KyberError> { + check_len("ct", ct_len, ct.len())?; + check_len("ss", KYBER_SSBYTES, ss.len())?; + check_len("pk", pk_len, pk.len())?; + if let Some(m) = seed { + check_len("seed", KYBER_SYMBYTES, m.len())?; + } + Ok(()) +} pub fn crypto_kem_keypair( pk: &mut [u8], @@ -18,6 +61,8 @@ pub fn crypto_kem_keypair( where R: RngCore + CryptoRng, { + check_keypair_args(pk, sk, _seed, KYBER_PUBLICKEYBYTES, KYBER_SECRETKEYBYTES)?; + const PK_START: usize = KYBER_SECRETKEYBYTES - (2 * KYBER_SYMBYTES); const SK_START: usize = KYBER_SECRETKEYBYTES - KYBER_SYMBYTES; const END: usize = KYBER_INDCPA_PUBLICKEYBYTES + KYBER_INDCPA_SECRETKEYBYTES; @@ -28,9 +73,16 @@ where hash_sha3_256(&mut sk[PK_START..], pk, KYBER_PUBLICKEYBYTES); if let Some(s) = _seed { - sk[SK_START..].copy_from_slice(&s.1) + sk[SK_START..].copy_from_slice(s.1) } else { - _rng.fill_bytes(&mut sk[SK_START..SK_START + KYBER_SYMBYTES]); + if _rng + .try_fill_bytes(&mut sk[SK_START..SK_START + KYBER_SYMBYTES]) + .is_err() + { + // sk already holds the IND-CPA secret key; do not hand it back. + sk.zeroize(); + return Err(KyberError::RandomBytesGeneration); + } } Ok(()) } @@ -45,17 +97,27 @@ pub fn crypto_kem_enc( where R: RngCore + CryptoRng, { + check_enc_args( + ct, + ss, + pk, + _seed, + KYBER_CIPHERTEXTBYTES, + KYBER_PUBLICKEYBYTES, + )?; + let mut kr = [0u8; 2 * KYBER_SYMBYTES]; let mut buf = [0u8; 2 * KYBER_SYMBYTES]; - let mut randbuf = [0u8; 2 * KYBER_SYMBYTES]; + let mut randbuf = Zeroizing::new([0u8; 2 * KYBER_SYMBYTES]); if let Some(s) = _seed { - randbuf[..KYBER_SYMBYTES].copy_from_slice(&s); + randbuf[..KYBER_SYMBYTES].copy_from_slice(s); } else { - _rng.fill_bytes(&mut randbuf); + _rng.try_fill_bytes(&mut randbuf[..]) + .map_err(|_| KyberError::RandomBytesGeneration)?; } - hash_sha3_256(&mut buf, &randbuf, KYBER_SYMBYTES); + hash_sha3_256(&mut buf, &randbuf[..], KYBER_SYMBYTES); hash_sha3_256(&mut buf[KYBER_SYMBYTES..], pk, KYBER_PUBLICKEYBYTES); hash_sha3_512(&mut kr, &buf, 2 * KYBER_SYMBYTES); @@ -89,29 +151,28 @@ pub fn crypto_kem_dec(ss: &mut [u8], ct: &[u8], sk: &[u8]) -> Result<(), KyberEr return Err(KyberError::invalid_input("ss", KYBER_SSBYTES, ss.len())); } - let mut buf = [0u8; 2 * KYBER_SYMBYTES]; - let mut kr = [0u8; 2 * KYBER_SYMBYTES]; - let mut cmp = [0u8; KYBER_CIPHERTEXTBYTES]; + let mut buf = Zeroizing::new([0u8; 2 * KYBER_SYMBYTES]); + let mut kr = Zeroizing::new([0u8; 2 * KYBER_SYMBYTES]); + let mut cmp = Zeroizing::new([0u8; KYBER_CIPHERTEXTBYTES]); let mut pk = [0u8; KYBER_INDCPA_PUBLICKEYBYTES]; pk.copy_from_slice(&sk[KYBER_INDCPA_SECRETKEYBYTES..][..KYBER_INDCPA_PUBLICKEYBYTES]); - indcpa_dec(&mut buf, ct, sk); + indcpa_dec(&mut buf[..], ct, sk); const START: usize = KYBER_SECRETKEYBYTES - 2 * KYBER_SYMBYTES; const END: usize = KYBER_SECRETKEYBYTES - KYBER_SYMBYTES; buf[KYBER_SYMBYTES..].copy_from_slice(&sk[START..END]); - hash_sha3_512(&mut kr, &buf, 2 * KYBER_SYMBYTES); + hash_sha3_512(&mut kr[..], &buf[..], 2 * KYBER_SYMBYTES); - indcpa_enc(&mut cmp, &buf, &pk, &kr[KYBER_SYMBYTES..]); - let fail = verify(ct, &cmp, KYBER_CIPHERTEXTBYTES); + indcpa_enc(&mut cmp[..], &buf[..], &pk, &kr[KYBER_SYMBYTES..]); + // The reject flag is secret: it never leaves this function and is wiped on drop. + let fail = Zeroizing::new(verify(ct, &cmp[..], KYBER_CIPHERTEXTBYTES)); hash_sha3_256(&mut kr[KYBER_SYMBYTES..], ct, KYBER_CIPHERTEXTBYTES); - cmov(&mut kr, &sk[END..], KYBER_SYMBYTES, fail); - shake256_kdf(ss, &kr, 2 * KYBER_SYMBYTES); + cmov(&mut kr[..], &sk[END..], KYBER_SYMBYTES, *fail); + shake256_kdf(ss, &kr[..], 2 * KYBER_SYMBYTES); - buf.zeroize(); - kr.zeroize(); - cmp.zeroize(); + // buf (m'), kr, cmp and the flag are `Zeroizing`: wiped on every exit, unwinding included. pk.zeroize(); Ok(()) } @@ -125,6 +186,14 @@ pub fn crypto_kem_keypair_512( where R: RngCore + CryptoRng, { + check_keypair_args( + pk, + sk, + _seed, + KYBER_512_PUBLICKEYBYTES, + KYBER_512_SECRETKEYBYTES, + )?; + const PK_START: usize = KYBER_512_SECRETKEYBYTES - (2 * KYBER_SYMBYTES); const SK_START: usize = KYBER_512_SECRETKEYBYTES - KYBER_SYMBYTES; const END: usize = KYBER_512_INDCPA_PUBLICKEYBYTES + KYBER_512_INDCPA_SECRETKEYBYTES; @@ -136,9 +205,16 @@ where hash_sha3_256(&mut sk[PK_START..], pk, KYBER_512_PUBLICKEYBYTES); if let Some(s) = _seed { - sk[SK_START..].copy_from_slice(&s.1) + sk[SK_START..].copy_from_slice(s.1) } else { - _rng.fill_bytes(&mut sk[SK_START..SK_START + KYBER_SYMBYTES]); + if _rng + .try_fill_bytes(&mut sk[SK_START..SK_START + KYBER_SYMBYTES]) + .is_err() + { + // sk already holds the IND-CPA secret key; do not hand it back. + sk.zeroize(); + return Err(KyberError::RandomBytesGeneration); + } } Ok(()) } @@ -153,17 +229,27 @@ pub fn crypto_kem_enc_512( where R: RngCore + CryptoRng, { + check_enc_args( + ct, + ss, + pk, + _seed, + KYBER_512_CIPHERTEXTBYTES, + KYBER_512_PUBLICKEYBYTES, + )?; + let mut kr = [0u8; 2 * KYBER_SYMBYTES]; let mut buf = [0u8; 2 * KYBER_SYMBYTES]; - let mut randbuf = [0u8; 2 * KYBER_SYMBYTES]; + let mut randbuf = Zeroizing::new([0u8; 2 * KYBER_SYMBYTES]); if let Some(s) = _seed { - randbuf[..KYBER_SYMBYTES].copy_from_slice(&s); + randbuf[..KYBER_SYMBYTES].copy_from_slice(s); } else { - _rng.fill_bytes(&mut randbuf); + _rng.try_fill_bytes(&mut randbuf[..]) + .map_err(|_| KyberError::RandomBytesGeneration)?; } - hash_sha3_256(&mut buf, &randbuf, KYBER_SYMBYTES); + hash_sha3_256(&mut buf, &randbuf[..], KYBER_SYMBYTES); hash_sha3_256(&mut buf[KYBER_SYMBYTES..], pk, KYBER_512_PUBLICKEYBYTES); hash_sha3_512(&mut kr, &buf, 2 * KYBER_SYMBYTES); @@ -197,29 +283,28 @@ pub fn crypto_kem_dec_512(ss: &mut [u8], ct: &[u8], sk: &[u8]) -> Result<(), Kyb return Err(KyberError::invalid_input("ss", KYBER_SSBYTES, ss.len())); } - let mut buf = [0u8; 2 * KYBER_SYMBYTES]; - let mut kr = [0u8; 2 * KYBER_SYMBYTES]; - let mut cmp = [0u8; KYBER_512_CIPHERTEXTBYTES]; + let mut buf = Zeroizing::new([0u8; 2 * KYBER_SYMBYTES]); + let mut kr = Zeroizing::new([0u8; 2 * KYBER_SYMBYTES]); + let mut cmp = Zeroizing::new([0u8; KYBER_512_CIPHERTEXTBYTES]); let mut pk = [0u8; KYBER_512_INDCPA_PUBLICKEYBYTES]; pk.copy_from_slice(&sk[KYBER_512_INDCPA_SECRETKEYBYTES..][..KYBER_512_INDCPA_PUBLICKEYBYTES]); - indcpa_dec_512(&mut buf, ct, sk); + indcpa_dec_512(&mut buf[..], ct, sk); const START: usize = KYBER_512_SECRETKEYBYTES - 2 * KYBER_SYMBYTES; const END: usize = KYBER_512_SECRETKEYBYTES - KYBER_SYMBYTES; buf[KYBER_SYMBYTES..].copy_from_slice(&sk[START..END]); - hash_sha3_512(&mut kr, &buf, 2 * KYBER_SYMBYTES); + hash_sha3_512(&mut kr[..], &buf[..], 2 * KYBER_SYMBYTES); - indcpa_enc_512(&mut cmp, &buf, &pk, &kr[KYBER_SYMBYTES..]); - let fail = verify(ct, &cmp, KYBER_512_CIPHERTEXTBYTES); + indcpa_enc_512(&mut cmp[..], &buf[..], &pk, &kr[KYBER_SYMBYTES..]); + // The reject flag is secret: it never leaves this function and is wiped on drop. + let fail = Zeroizing::new(verify(ct, &cmp[..], KYBER_512_CIPHERTEXTBYTES)); hash_sha3_256(&mut kr[KYBER_SYMBYTES..], ct, KYBER_512_CIPHERTEXTBYTES); - cmov(&mut kr, &sk[END..], KYBER_SYMBYTES, fail); - shake256_kdf(ss, &kr, 2 * KYBER_SYMBYTES); + cmov(&mut kr[..], &sk[END..], KYBER_SYMBYTES, *fail); + shake256_kdf(ss, &kr[..], 2 * KYBER_SYMBYTES); - buf.zeroize(); - kr.zeroize(); - cmp.zeroize(); + // buf (m'), kr, cmp and the flag are `Zeroizing`: wiped on every exit, unwinding included. pk.zeroize(); Ok(()) } @@ -233,6 +318,14 @@ pub fn crypto_kem_keypair_1024( where R: RngCore + CryptoRng, { + check_keypair_args( + pk, + sk, + _seed, + KYBER_1024_PUBLICKEYBYTES, + KYBER_1024_SECRETKEYBYTES, + )?; + const PK_START: usize = KYBER_1024_SECRETKEYBYTES - (2 * KYBER_SYMBYTES); const SK_START: usize = KYBER_1024_SECRETKEYBYTES - KYBER_SYMBYTES; const END: usize = KYBER_1024_INDCPA_PUBLICKEYBYTES + KYBER_1024_INDCPA_SECRETKEYBYTES; @@ -244,9 +337,16 @@ where hash_sha3_256(&mut sk[PK_START..], pk, KYBER_1024_PUBLICKEYBYTES); if let Some(s) = _seed { - sk[SK_START..].copy_from_slice(&s.1) + sk[SK_START..].copy_from_slice(s.1) } else { - _rng.fill_bytes(&mut sk[SK_START..SK_START + KYBER_SYMBYTES]); + if _rng + .try_fill_bytes(&mut sk[SK_START..SK_START + KYBER_SYMBYTES]) + .is_err() + { + // sk already holds the IND-CPA secret key; do not hand it back. + sk.zeroize(); + return Err(KyberError::RandomBytesGeneration); + } } Ok(()) } @@ -261,17 +361,27 @@ pub fn crypto_kem_enc_1024( where R: RngCore + CryptoRng, { + check_enc_args( + ct, + ss, + pk, + _seed, + KYBER_1024_CIPHERTEXTBYTES, + KYBER_1024_PUBLICKEYBYTES, + )?; + let mut kr = [0u8; 2 * KYBER_SYMBYTES]; let mut buf = [0u8; 2 * KYBER_SYMBYTES]; - let mut randbuf = [0u8; 2 * KYBER_SYMBYTES]; + let mut randbuf = Zeroizing::new([0u8; 2 * KYBER_SYMBYTES]); if let Some(s) = _seed { - randbuf[..KYBER_SYMBYTES].copy_from_slice(&s); + randbuf[..KYBER_SYMBYTES].copy_from_slice(s); } else { - _rng.fill_bytes(&mut randbuf); + _rng.try_fill_bytes(&mut randbuf[..]) + .map_err(|_| KyberError::RandomBytesGeneration)?; } - hash_sha3_256(&mut buf, &randbuf, KYBER_SYMBYTES); + hash_sha3_256(&mut buf, &randbuf[..], KYBER_SYMBYTES); hash_sha3_256(&mut buf[KYBER_SYMBYTES..], pk, KYBER_1024_PUBLICKEYBYTES); hash_sha3_512(&mut kr, &buf, 2 * KYBER_SYMBYTES); @@ -305,29 +415,28 @@ pub fn crypto_kem_dec_1024(ss: &mut [u8], ct: &[u8], sk: &[u8]) -> Result<(), Ky return Err(KyberError::invalid_input("ss", KYBER_SSBYTES, ss.len())); } - let mut buf = [0u8; 2 * KYBER_SYMBYTES]; - let mut kr = [0u8; 2 * KYBER_SYMBYTES]; - let mut cmp = [0u8; KYBER_1024_CIPHERTEXTBYTES]; + let mut buf = Zeroizing::new([0u8; 2 * KYBER_SYMBYTES]); + let mut kr = Zeroizing::new([0u8; 2 * KYBER_SYMBYTES]); + let mut cmp = Zeroizing::new([0u8; KYBER_1024_CIPHERTEXTBYTES]); let mut pk = [0u8; KYBER_1024_INDCPA_PUBLICKEYBYTES]; pk.copy_from_slice(&sk[KYBER_1024_INDCPA_SECRETKEYBYTES..][..KYBER_1024_INDCPA_PUBLICKEYBYTES]); - indcpa_dec_1024(&mut buf, ct, sk); + indcpa_dec_1024(&mut buf[..], ct, sk); const START: usize = KYBER_1024_SECRETKEYBYTES - 2 * KYBER_SYMBYTES; const END: usize = KYBER_1024_SECRETKEYBYTES - KYBER_SYMBYTES; buf[KYBER_SYMBYTES..].copy_from_slice(&sk[START..END]); - hash_sha3_512(&mut kr, &buf, 2 * KYBER_SYMBYTES); + hash_sha3_512(&mut kr[..], &buf[..], 2 * KYBER_SYMBYTES); - indcpa_enc_1024(&mut cmp, &buf, &pk, &kr[KYBER_SYMBYTES..]); - let fail = verify(ct, &cmp, KYBER_1024_CIPHERTEXTBYTES); + indcpa_enc_1024(&mut cmp[..], &buf[..], &pk, &kr[KYBER_SYMBYTES..]); + // The reject flag is secret: it never leaves this function and is wiped on drop. + let fail = Zeroizing::new(verify(ct, &cmp[..], KYBER_1024_CIPHERTEXTBYTES)); hash_sha3_256(&mut kr[KYBER_SYMBYTES..], ct, KYBER_1024_CIPHERTEXTBYTES); - cmov(&mut kr, &sk[END..], KYBER_SYMBYTES, fail); - shake256_kdf(ss, &kr, 2 * KYBER_SYMBYTES); + cmov(&mut kr[..], &sk[END..], KYBER_SYMBYTES, *fail); + shake256_kdf(ss, &kr[..], 2 * KYBER_SYMBYTES); - buf.zeroize(); - kr.zeroize(); - cmp.zeroize(); + // buf (m'), kr, cmp and the flag are `Zeroizing`: wiped on every exit, unwinding included. pk.zeroize(); Ok(()) } diff --git a/src/kyber/ntt.rs b/src/kyber/ntt.rs index 42228a9..450b478 100644 --- a/src/kyber/ntt.rs +++ b/src/kyber/ntt.rs @@ -72,6 +72,7 @@ pub fn ntt_inverse(coeffs: &mut [i16]) { } layer_length <<= 1; } + #[allow(clippy::needless_range_loop)] // reference-C loop shape, see poly.rs for j in 0..256 { coeffs[j] = field_multiply(coeffs[j], F); } diff --git a/src/kyber/poly.rs b/src/kyber/poly.rs index 669a08e..3b26418 100644 --- a/src/kyber/poly.rs +++ b/src/kyber/poly.rs @@ -1,3 +1,12 @@ +// These loops and `+ 0` / `* 1` offsets mirror the reference C layout of the +// round-3 Kyber code line for line. Rewriting them (iterators, dropped +// no-op terms) would touch the hot path for no gain, so the lints are +// allowed here instead. +#![allow( + clippy::needless_range_loop, + clippy::identity_op, + clippy::explicit_counter_loop +)] use super::cbd::poly_cbd_eta1; use super::cbd::poly_cbd_eta1_512; use super::cbd::poly_cbd_eta2; @@ -6,7 +15,8 @@ use super::params::*; use super::reduce::{barrett_reduce, montgomery_reduce}; use super::symmetric::shake256_prf_expand; -use zeroize::Zeroize; +use core::hint::black_box; +use zeroize::{Zeroize, Zeroizing}; #[derive(Clone, Zeroize)] #[zeroize(drop)] @@ -123,30 +133,34 @@ pub fn poly_frombytes(r: &mut Poly, a: &[u8]) { pub fn poly_getnoise_eta1(r: &mut Poly, seed: &[u8], nonce: u8) { const LENGTH: usize = KYBER_ETA1 * KYBER_N / 4; - let mut buf = [0u8; LENGTH]; - shake256_prf_expand(&mut buf, LENGTH, seed, nonce); - poly_cbd_eta1(r, &buf); + // The PRF output is the secret noise (the key in key generation, r in encryption). + let mut buf = Zeroizing::new([0u8; LENGTH]); + shake256_prf_expand(&mut buf[..], LENGTH, seed, nonce); + poly_cbd_eta1(r, &buf[..]); } pub fn poly_getnoise_eta1_512(r: &mut Poly, seed: &[u8], nonce: u8) { const LENGTH: usize = KYBER_512_ETA1 * KYBER_N / 4; - let mut buf = [0u8; LENGTH]; - shake256_prf_expand(&mut buf, LENGTH, seed, nonce); - poly_cbd_eta1_512(r, &buf); + // The PRF output is the secret noise (the key in key generation, r in encryption). + let mut buf = Zeroizing::new([0u8; LENGTH]); + shake256_prf_expand(&mut buf[..], LENGTH, seed, nonce); + poly_cbd_eta1_512(r, &buf[..]); } pub fn poly_getnoise_eta2(r: &mut Poly, seed: &[u8], nonce: u8) { const LENGTH: usize = KYBER_ETA2 * KYBER_N / 4; - let mut buf = [0u8; LENGTH]; - shake256_prf_expand(&mut buf, LENGTH, seed, nonce); - poly_cbd_eta2(r, &buf); + // The PRF output is the secret noise (the key in key generation, r in encryption). + let mut buf = Zeroizing::new([0u8; LENGTH]); + shake256_prf_expand(&mut buf[..], LENGTH, seed, nonce); + poly_cbd_eta2(r, &buf[..]); } pub fn poly_getnoise_eta2_512(r: &mut Poly, seed: &[u8], nonce: u8) { const LENGTH: usize = KYBER_512_ETA2 * KYBER_N / 4; - let mut buf = [0u8; LENGTH]; - shake256_prf_expand(&mut buf, LENGTH, seed, nonce); - poly_cbd_eta2(r, &buf); + // The PRF output is the secret noise (the key in key generation, r in encryption). + let mut buf = Zeroizing::new([0u8; LENGTH]); + shake256_prf_expand(&mut buf[..], LENGTH, seed, nonce); + poly_cbd_eta2(r, &buf[..]); } pub fn poly_ntt(r: &mut Poly) { @@ -201,12 +215,20 @@ pub fn poly_sub(r: &mut Poly, a: &Poly) { } } +/// Expands each message bit to 0 or (q + 1) / 2. +/// +/// The mask `-(bit)` is the pattern behind CVE-2024-37880 ("Clangover"), where +/// the compiler turned it into a branch on the message bit. `black_box` is an +/// optimization barrier: the compiler may not assume what the bit or the mask +/// is, so it cannot rewrite the mask into a conditional. It is best effort, not a +/// guarantee (see SECURITY.md). pub fn poly_frommsg(r: &mut Poly, msg: &[u8]) { let mut mask; for i in 0..KYBER_N / 8 { for j in 0..8 { - mask = ((msg[i] as u16 >> j) & 1).wrapping_neg(); - r.coeffs[8 * i + j] = (mask & ((KYBER_Q + 1) / 2) as u16) as i16; + let bit = black_box((msg[i] as u16 >> j) & 1); + mask = black_box(bit.wrapping_neg()); + r.coeffs[8 * i + j] = (mask & KYBER_Q.div_ceil(2) as u16) as i16; } } } diff --git a/src/kyber/polyvec.rs b/src/kyber/polyvec.rs index 92712d7..2331a01 100644 --- a/src/kyber/polyvec.rs +++ b/src/kyber/polyvec.rs @@ -1,4 +1,6 @@ #![allow(clippy::precedence)] +// Index loops and `+ 0` offsets follow the reference C code; see poly.rs. +#![allow(clippy::needless_range_loop, clippy::identity_op)] use super::params::*; use super::poly::*; diff --git a/src/kyber/verify.rs b/src/kyber/verify.rs index ef9f7fd..629727b 100644 --- a/src/kyber/verify.rs +++ b/src/kyber/verify.rs @@ -1,15 +1,35 @@ +//! Comparison and conditional move for the round-3 Kyber re-encryption check. +//! +//! The standard library has no constant-time guarantee. `black_box` is an +//! optimization barrier: the compiler may not assume what the accumulated +//! difference or the select mask is, so it cannot prove them to be 0/1 and +//! rewrite the select as a branch (the pattern behind CVE-2024-37880, +//! "Clangover"). It is best effort, so the release assembly is checked (see +//! SECURITY.md). The ML-KEM side does the same in `mlkem::ct`. `len` is public +//! and must not exceed either slice. + +use core::hint::black_box; + +/// Shift that moves the top bit of a `u16` to bit 0. +const FLAG_SHIFT: u32 = u16::BITS - 1; + +/// Returns 0 if the first `len` bytes of `a` and `b` are equal, 1 otherwise. pub fn verify(a: &[u8], b: &[u8], len: usize) -> u8 { - let mut r = 0u64; + let mut r = 0u8; for i in 0..len { - r |= (a[i] ^ b[i]) as u64; + r |= a[i] ^ b[i]; } - r = r.wrapping_neg() >> 63; - r as u8 + // bit = 1 if r != 0 else 0: for r in 1..=255 the top bit of `-r` as a u16 is set. + let wide = u16::from(black_box(r)); + black_box(((wide | wide.wrapping_neg()) >> FLAG_SHIFT) as u8) } -pub fn cmov(r: &mut [u8], x: &[u8], len: usize, mut b: u8) { - b = b.wrapping_neg(); +/// Copies the first `len` bytes of `x` into `r` if `b` is 1, and leaves `r` +/// alone if `b` is 0. `b` must be 0 or 1. +pub fn cmov(r: &mut [u8], x: &[u8], len: usize, b: u8) { + // 0x00 (keep r) or 0xff (take x). + let mask = black_box(black_box(b).wrapping_neg()); for i in 0..len { - r[i] ^= b & (x[i] ^ r[i]); + r[i] ^= mask & (x[i] ^ r[i]); } } diff --git a/src/lib.rs b/src/lib.rs index 0d8274d..076f1f6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,6 @@ pub mod kyber; +pub(crate) mod mlkem; +mod pymlkem; use kyber::{ crypto_kem_dec_1024, crypto_kem_dec_512, crypto_kem_enc_1024, crypto_kem_enc_512, @@ -8,34 +10,77 @@ use kyber::{ }; use pyo3::exceptions::PyException; use pyo3::prelude::*; -use rand::thread_rng; +use pyo3::types::PyBytes; +use rand::rngs::OsRng; +use zeroize::{Zeroize, Zeroizing}; fn make_kyber_error(msg: &str) -> PyErr { PyException::new_err(msg.to_string()) } +/// Secret bytes (a secret key or a shared secret) on their way to Python. The +/// Python `bytes` object is built from a copy, and this Rust buffer is wiped +/// when it is dropped right after, instead of being freed with the secret in it. +/// The Python object itself cannot be wiped. +struct SecretBytes(Zeroizing>); + +impl SecretBytes { + fn new(bytes: Vec) -> Self { + SecretBytes(Zeroizing::new(bytes)) + } + + /// Copies a fixed-size secret and wipes the original. + fn from_array(mut bytes: [u8; N]) -> Self { + let copy = SecretBytes::new(bytes.to_vec()); + bytes.zeroize(); + copy + } +} + +impl<'py> IntoPyObject<'py> for SecretBytes { + type Target = PyBytes; + type Output = Bound<'py, PyBytes>; + type Error = std::convert::Infallible; + + fn into_pyobject(self, py: Python<'py>) -> Result { + Ok(PyBytes::new(py, &self.0)) + } +} + +/// A failed OS random generator is raised as `pykyber.RandomnessError` (a +/// `KyberError` subclass), like the ML-KEM functions do. Every other error keeps +/// the plain exception the legacy functions have always raised. +fn legacy_err(error: &KyberError) -> PyErr { + match error { + KyberError::RandomBytesGeneration => { + Python::attach(|py| pymlkem::to_py_err(py, KyberError::RandomBytesGeneration)) + } + _ => make_kyber_error(&error.to_string()), + } +} + #[pyfunction] -fn generate_keypair(seed: Option<&[u8]>) -> PyResult<(Vec, Vec)> { +fn generate_keypair(seed: Option<&[u8]>) -> PyResult<(Vec, SecretBytes)> { if let Some(s) = seed { if s.len() != 64 { return Err(make_kyber_error("Seed must be 64 bytes")); } kyber::derive(s) - .map(|keys| (keys.public.to_vec(), keys.secret.to_vec())) - .map_err(|e| make_kyber_error(&e.to_string())) + .map(|keys| (keys.public.to_vec(), SecretBytes::new(keys.secret.to_vec()))) + .map_err(|e| legacy_err(&e)) } else { - let mut rng = thread_rng(); + let mut rng = OsRng; keypair(&mut rng) - .map(|keys| (keys.public.to_vec(), keys.secret.to_vec())) - .map_err(|e| make_kyber_error(&e.to_string())) + .map(|keys| (keys.public.to_vec(), SecretBytes::new(keys.secret.to_vec()))) + .map_err(|e| legacy_err(&e)) } } #[pyfunction] -fn generate_keypair_512(seed: Option<&[u8]>) -> PyResult<(Vec, Vec)> { - let mut rng = thread_rng(); +fn generate_keypair_512(seed: Option<&[u8]>) -> PyResult<(Vec, SecretBytes)> { + let mut rng = OsRng; let mut pk = vec![0u8; KYBER_512_PUBLICKEYBYTES]; - let mut sk = vec![0u8; KYBER_512_SECRETKEYBYTES]; + let mut sk = Zeroizing::new(vec![0u8; KYBER_512_SECRETKEYBYTES]); let s_opt = if let Some(s) = seed { if s.len() != 64 { return Err(make_kyber_error("Seed must be 64 bytes")); @@ -45,15 +90,15 @@ fn generate_keypair_512(seed: Option<&[u8]>) -> PyResult<(Vec, Vec)> { None }; crypto_kem_keypair_512(&mut pk, &mut sk, &mut rng, s_opt) - .map(|_| (pk, sk)) - .map_err(|e| make_kyber_error(&e.to_string())) + .map(|_| (pk, SecretBytes(sk))) + .map_err(|e| legacy_err(&e)) } #[pyfunction] -fn generate_keypair_1024(seed: Option<&[u8]>) -> PyResult<(Vec, Vec)> { - let mut rng = thread_rng(); +fn generate_keypair_1024(seed: Option<&[u8]>) -> PyResult<(Vec, SecretBytes)> { + let mut rng = OsRng; let mut pk = vec![0u8; KYBER_1024_PUBLICKEYBYTES]; - let mut sk = vec![0u8; KYBER_1024_SECRETKEYBYTES]; + let mut sk = Zeroizing::new(vec![0u8; KYBER_1024_SECRETKEYBYTES]); let s_opt = if let Some(s) = seed { if s.len() != 64 { return Err(make_kyber_error("Seed must be 64 bytes")); @@ -63,57 +108,57 @@ fn generate_keypair_1024(seed: Option<&[u8]>) -> PyResult<(Vec, Vec)> { None }; crypto_kem_keypair_1024(&mut pk, &mut sk, &mut rng, s_opt) - .map(|_| (pk, sk)) - .map_err(|e| make_kyber_error(&e.to_string())) + .map(|_| (pk, SecretBytes(sk))) + .map_err(|e| legacy_err(&e)) } #[pyfunction] -fn encapsulate_key(pk: &[u8]) -> PyResult<(Vec, Vec)> { - let mut rng = thread_rng(); +fn encapsulate_key(pk: &[u8]) -> PyResult<(Vec, SecretBytes)> { + let mut rng = OsRng; encapsulate(pk, &mut rng) - .map(|(ct, ss)| (ct.to_vec(), ss.to_vec())) - .map_err(|e| make_kyber_error(&e.to_string())) + .map(|(ct, ss)| (ct.to_vec(), SecretBytes::from_array(ss))) + .map_err(|e| legacy_err(&e)) } #[pyfunction] -fn decapsulate_key(ct: &[u8], sk: &[u8]) -> PyResult> { +fn decapsulate_key(ct: &[u8], sk: &[u8]) -> PyResult { decapsulate(ct, sk) - .map(|ss| ss.to_vec()) + .map(SecretBytes::from_array) .map_err(|e| make_kyber_error(&e.to_string())) } #[pyfunction] -fn encapsulate_key_512(pk: &[u8]) -> PyResult<(Vec, Vec)> { +fn encapsulate_key_512(pk: &[u8]) -> PyResult<(Vec, SecretBytes)> { if pk.len() != KYBER_512_PUBLICKEYBYTES { return Err(make_kyber_error( &KyberError::invalid_input("pk", KYBER_512_PUBLICKEYBYTES, pk.len()).to_string(), )); } - let mut rng = thread_rng(); + let mut rng = OsRng; let mut ct = vec![0u8; KYBER_512_CIPHERTEXTBYTES]; - let mut ss = vec![0u8; 32]; + let mut ss = Zeroizing::new(vec![0u8; 32]); crypto_kem_enc_512(&mut ct, &mut ss, pk, &mut rng, None) - .map(|_| (ct, ss)) - .map_err(|e| make_kyber_error(&e.to_string())) + .map(|_| (ct, SecretBytes(ss))) + .map_err(|e| legacy_err(&e)) } #[pyfunction] -fn encapsulate_key_1024(pk: &[u8]) -> PyResult<(Vec, Vec)> { +fn encapsulate_key_1024(pk: &[u8]) -> PyResult<(Vec, SecretBytes)> { if pk.len() != KYBER_1024_PUBLICKEYBYTES { return Err(make_kyber_error( &KyberError::invalid_input("pk", KYBER_1024_PUBLICKEYBYTES, pk.len()).to_string(), )); } - let mut rng = thread_rng(); + let mut rng = OsRng; let mut ct = vec![0u8; KYBER_1024_CIPHERTEXTBYTES]; - let mut ss = vec![0u8; 32]; + let mut ss = Zeroizing::new(vec![0u8; 32]); crypto_kem_enc_1024(&mut ct, &mut ss, pk, &mut rng, None) - .map(|_| (ct, ss)) - .map_err(|e| make_kyber_error(&e.to_string())) + .map(|_| (ct, SecretBytes(ss))) + .map_err(|e| legacy_err(&e)) } #[pyfunction] -fn decapsulate_key_512(ct: &[u8], sk: &[u8]) -> PyResult> { +fn decapsulate_key_512(ct: &[u8], sk: &[u8]) -> PyResult { if ct.len() != KYBER_512_CIPHERTEXTBYTES { return Err(make_kyber_error( &KyberError::invalid_input("ct", KYBER_512_CIPHERTEXTBYTES, ct.len()).to_string(), @@ -124,14 +169,14 @@ fn decapsulate_key_512(ct: &[u8], sk: &[u8]) -> PyResult> { &KyberError::invalid_input("sk", KYBER_512_SECRETKEYBYTES, sk.len()).to_string(), )); } - let mut ss = vec![0u8; 32]; + let mut ss = Zeroizing::new(vec![0u8; 32]); crypto_kem_dec_512(&mut ss, ct, sk) - .map(|_| ss) + .map(|_| SecretBytes(ss)) .map_err(|e| make_kyber_error(&e.to_string())) } #[pyfunction] -fn decapsulate_key_1024(ct: &[u8], sk: &[u8]) -> PyResult> { +fn decapsulate_key_1024(ct: &[u8], sk: &[u8]) -> PyResult { if ct.len() != KYBER_1024_CIPHERTEXTBYTES { return Err(make_kyber_error( &KyberError::invalid_input("ct", KYBER_1024_CIPHERTEXTBYTES, ct.len()).to_string(), @@ -142,9 +187,9 @@ fn decapsulate_key_1024(ct: &[u8], sk: &[u8]) -> PyResult> { &KyberError::invalid_input("sk", KYBER_1024_SECRETKEYBYTES, sk.len()).to_string(), )); } - let mut ss = vec![0u8; 32]; + let mut ss = Zeroizing::new(vec![0u8; 32]); crypto_kem_dec_1024(&mut ss, ct, sk) - .map(|_| ss) + .map(|_| SecretBytes(ss)) .map_err(|e| make_kyber_error(&e.to_string())) } @@ -152,63 +197,70 @@ fn decapsulate_key_1024(ct: &[u8], sk: &[u8]) -> PyResult> { mod _pykyber { use pyo3::prelude::*; + #[pymodule_export] + use super::pymlkem::{ + _mlkem_1024_decaps, _mlkem_1024_encaps, _mlkem_1024_keygen, _mlkem_512_decaps, + _mlkem_512_encaps, _mlkem_512_keygen, _mlkem_768_decaps, _mlkem_768_encaps, + _mlkem_768_keygen, + }; + #[pyfunction] - pub fn _generate_keypair(seed: Option<&[u8]>) -> PyResult<(Vec, Vec)> { + pub fn _generate_keypair(seed: Option<&[u8]>) -> PyResult<(Vec, super::SecretBytes)> { super::generate_keypair(seed) } #[pyfunction] - pub fn _encapsulate(pk: &[u8]) -> PyResult<(Vec, Vec)> { + pub fn _encapsulate(pk: &[u8]) -> PyResult<(Vec, super::SecretBytes)> { super::encapsulate_key(pk) } #[pyfunction] - pub fn _decapsulate(ct: &[u8], sk: &[u8]) -> PyResult> { + pub fn _decapsulate(ct: &[u8], sk: &[u8]) -> PyResult { super::decapsulate_key(ct, sk) } #[pyfunction] - pub fn _keypair_512(seed: Option<&[u8]>) -> PyResult<(Vec, Vec)> { + pub fn _keypair_512(seed: Option<&[u8]>) -> PyResult<(Vec, super::SecretBytes)> { super::generate_keypair_512(seed) } #[pyfunction] - pub fn _keypair_768(seed: Option<&[u8]>) -> PyResult<(Vec, Vec)> { + pub fn _keypair_768(seed: Option<&[u8]>) -> PyResult<(Vec, super::SecretBytes)> { super::generate_keypair(seed) } #[pyfunction] - pub fn _keypair_1024(seed: Option<&[u8]>) -> PyResult<(Vec, Vec)> { + pub fn _keypair_1024(seed: Option<&[u8]>) -> PyResult<(Vec, super::SecretBytes)> { super::generate_keypair_1024(seed) } #[pyfunction] - pub fn _encapsulate_512(pk: &[u8]) -> PyResult<(Vec, Vec)> { + pub fn _encapsulate_512(pk: &[u8]) -> PyResult<(Vec, super::SecretBytes)> { super::encapsulate_key_512(pk) } #[pyfunction] - pub fn _encapsulate_768(pk: &[u8]) -> PyResult<(Vec, Vec)> { + pub fn _encapsulate_768(pk: &[u8]) -> PyResult<(Vec, super::SecretBytes)> { super::encapsulate_key(pk) } #[pyfunction] - pub fn _encapsulate_1024(pk: &[u8]) -> PyResult<(Vec, Vec)> { + pub fn _encapsulate_1024(pk: &[u8]) -> PyResult<(Vec, super::SecretBytes)> { super::encapsulate_key_1024(pk) } #[pyfunction] - pub fn _decapsulate_512(ct: &[u8], sk: &[u8]) -> PyResult> { + pub fn _decapsulate_512(ct: &[u8], sk: &[u8]) -> PyResult { super::decapsulate_key_512(ct, sk) } #[pyfunction] - pub fn _decapsulate_768(ct: &[u8], sk: &[u8]) -> PyResult> { + pub fn _decapsulate_768(ct: &[u8], sk: &[u8]) -> PyResult { super::decapsulate_key(ct, sk) } #[pyfunction] - pub fn _decapsulate_1024(ct: &[u8], sk: &[u8]) -> PyResult> { + pub fn _decapsulate_1024(ct: &[u8], sk: &[u8]) -> PyResult { super::decapsulate_key_1024(ct, sk) } } diff --git a/src/mlkem/acvp_tests.rs b/src/mlkem/acvp_tests.rs new file mode 100644 index 0000000..b1ac65f --- /dev/null +++ b/src/mlkem/acvp_tests.rs @@ -0,0 +1,276 @@ +//! K-PKE against the NIST ACVP ML-KEM vectors (ACVP-Server, internalProjection +//! files, fetched by `scripts/fetch_vectors.py` and never committed). +//! +//! KeyGen_internal (Algorithm 16) starts with K-PKE.KeyGen(d), so the keyGen +//! vectors pin `ek_PKE` and `dk_PKE` through `kpke`, and the whole `(ek, dk)` +//! through `kem::acvp::keygen_internal`. The encapDecap vectors run +//! `encaps_internal` and `decaps_internal` (Algorithms 17 and 18) and the key +//! checks of FIPS 203 sections 7.2 and 7.3. The `FIPS203-tr1` encapDecap +//! revision (a new test revision against the same standard, ACVP ML-KEM draft +//! section 5) adds cases for the same functions plus decapsulation groups whose +//! key is given as the seed `d || z`. A missing file fails the test: run the +//! fetch script. + +use super::check::{check_dk, check_ek}; +use super::kem::acvp; +use super::kpke::{encrypt, keygen}; +use super::params::{MlKem1024, MlKem512, MlKem768, ParamSet}; +use crate::kyber::fips202::{sha3_256, sha3_512}; +use crate::kyber::params::KYBER_SYMBYTES; +use serde_json::Value; + +/// Folder the fetch script fills, under `tests/vectors/external`. +const ACVP_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/vectors/external/acvp/"); +const KEYGEN_FILE: &str = "ML-KEM-keyGen-FIPS203/internalProjection.json"; +const ENCAP_DECAP_FILE: &str = "ML-KEM-encapDecap-FIPS203/internalProjection.json"; +const TR1_FILE: &str = "ML-KEM-encapDecap-FIPS203-tr1/internalProjection.json"; +/// Hex digits per byte. +const HEX_DIGITS_PER_BYTE: usize = 2; +const HEX_RADIX: u32 = 16; +const SEED_BYTES: usize = KYBER_SYMBYTES; + +fn load(file: &str) -> Value { + let path = format!("{ACVP_DIR}{file}"); + let text = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("{path}: {e}; run scripts/fetch_vectors.py")); + serde_json::from_str(&text).expect("ACVP file is JSON") +} + +fn hex(value: &Value, field: &str) -> Vec { + let digits = value[field] + .as_str() + .unwrap_or_else(|| panic!("no {field}")); + (0..digits.len()) + .step_by(HEX_DIGITS_PER_BYTE) + .map(|i| u8::from_str_radix(&digits[i..i + HEX_DIGITS_PER_BYTE], HEX_RADIX).unwrap()) + .collect() +} + +fn seed(value: &Value, field: &str) -> [u8; SEED_BYTES] { + hex(value, field).try_into().expect("32-byte field") +} + +fn h(input: &[u8]) -> Vec { + let mut out = vec![0u8; SEED_BYTES]; + sha3_256(&mut out, input, input.len()); + out +} + +fn g(input: &[u8]) -> Vec { + let mut out = vec![0u8; 2 * SEED_BYTES]; + sha3_512(&mut out, input, input.len()); + out +} + +/// Run `check` for the groups of `function` (None for keyGen groups). +fn for_groups(file: &str, function: Option<&str>, check: fn(usize, &Value) -> usize) -> usize { + for_groups_where( + file, + |group| function.is_none() || group["function"].as_str() == function, + check, + ) +} + +/// Run `check` for the groups of `file` that `wanted` accepts; returns the case count. +fn for_groups_where( + file: &str, + wanted: impl Fn(&Value) -> bool, + check: fn(usize, &Value) -> usize, +) -> usize { + let mut cases = 0; + for group in load(file)["testGroups"].as_array().unwrap() { + if !wanted(group) { + continue; + } + let k = match group["parameterSet"].as_str().unwrap() { + "ML-KEM-512" => MlKem512::K, + "ML-KEM-768" => MlKem768::K, + "ML-KEM-1024" => MlKem1024::K, + other => panic!("unknown parameter set {other}"), + }; + for test in group["tests"].as_array().unwrap() { + cases += check(k, test); + } + } + cases +} + +/// Calls the generic `$f::` for the runtime `k` of a vector group. +macro_rules! by_k { + ($f:ident, $k:expr, $($arg:expr),*) => { + match $k { + MlKem512::K => $f::<{ MlKem512::K }>($($arg),*), + MlKem768::K => $f::<{ MlKem768::K }>($($arg),*), + MlKem1024::K => $f::<{ MlKem1024::K }>($($arg),*), + other => panic!("unsupported k = {other}"), + } + }; +} + +fn keygen_case(t: &Value) -> usize { + let (ek, dk) = keygen::(&seed(t, "d")); + assert_eq!(ek, hex(t, "ek"), "tcId {}", t["tcId"]); + // dk = dk_PKE || ek || H(ek) || z (Algorithm 16 step 3). + assert_eq!( + dk.to_vec(), + hex(t, "dk")[..ParamSet::::DK_PKE_BYTES], + "tcId {}", + t["tcId"] + ); + 1 +} + +#[test] +fn k_pke_keygen_matches_acvp() { + let cases = for_groups(KEYGEN_FILE, None, |k, t| by_k!(keygen_case, k, t)); + assert_eq!(cases, 3 * 25); +} + +/// Algorithm 17 steps 1-2: (K, r) = G(m || H(ek)); c = K-PKE.Encrypt(ek, m, r). +fn encaps_case(t: &Value) -> usize { + let (ek, m) = (hex(t, "ek"), seed(t, "m")); + let kr = g(&[&m[..], &h(&ek)].concat()); + assert_eq!(kr[..SEED_BYTES], hex(t, "k"), "tcId {}", t["tcId"]); + let r = kr[SEED_BYTES..].try_into().unwrap(); + assert_eq!( + encrypt::(&ek, &m, &r).unwrap(), + hex(t, "c"), + "tcId {}", + t["tcId"] + ); + 1 +} + +#[test] +fn k_pke_encrypt_matches_acvp_encapsulation() { + let cases = for_groups(ENCAP_DECAP_FILE, Some("encapsulation"), |k, t| { + by_k!(encaps_case, k, t) + }); + assert_eq!(cases, 3 * 25); +} + +/// Algorithm 17 end to end: `encaps_internal(ek, m)` gives `(k, c)`. +fn encaps_internal_case(t: &Value) -> usize { + let (k, c) = acvp::encaps_internal::(&hex(t, "ek"), &seed(t, "m")).unwrap(); + assert_eq!(*k, hex(t, "k")[..], "tcId {}", t["tcId"]); + assert_eq!(c, hex(t, "c"), "tcId {}", t["tcId"]); + 1 +} + +#[test] +fn encaps_internal_matches_acvp() { + let cases = for_groups(ENCAP_DECAP_FILE, Some("encapsulation"), |k, t| { + by_k!(encaps_internal_case, k, t) + }); + assert_eq!(cases, 3 * 25); +} + +/// Algorithm 16 end to end: `keygen_internal(d, z)` gives the whole `(ek, dk)`. +fn keygen_internal_case(t: &Value) -> usize { + let (ek, dk) = acvp::keygen_internal::(&seed(t, "d"), &seed(t, "z")); + assert_eq!(ek, hex(t, "ek"), "tcId {}", t["tcId"]); + assert_eq!(dk.to_vec(), hex(t, "dk"), "tcId {}", t["tcId"]); + 1 +} + +#[test] +fn keygen_internal_matches_acvp() { + let cases = for_groups(KEYGEN_FILE, None, |k, t| by_k!(keygen_internal_case, k, t)); + assert_eq!(cases, 3 * 25); +} + +/// Algorithm 18: the expected secret is `K'` for a valid ciphertext and +/// `J(z || c)` for a modified one, so one equality covers both. +fn decaps_internal_case(t: &Value) -> usize { + let k = acvp::decaps_internal::(&hex(t, "dk"), &hex(t, "c")).unwrap(); + assert_eq!(*k, hex(t, "k")[..], "tcId {}", t["tcId"]); + 1 +} + +#[test] +fn decaps_internal_matches_acvp() { + let cases = for_groups(ENCAP_DECAP_FILE, Some("decapsulation"), |k, t| { + by_k!(decaps_internal_case, k, t) + }); + // Five valid and five modified ciphertexts in each of the three groups. + assert_eq!(cases, 3 * 10); +} + +/// Sections 7.2 and 7.3: `testPassed` says whether the check accepts the key. +fn key_check_case(t: &Value, encapsulation: bool) -> usize { + let passed = t["testPassed"].as_bool().unwrap(); + let result = if encapsulation { + check_ek::(&hex(t, "ek")) + } else { + check_dk::(&hex(t, "dk")) + }; + assert_eq!(result.is_ok(), passed, "tcId {}: {result:?}", t["tcId"]); + 1 +} + +#[test] +fn key_checks_match_acvp() { + let ek_cases = for_groups(ENCAP_DECAP_FILE, Some("encapsulationKeyCheck"), |k, t| { + by_k!(key_check_case, k, t, true) + }); + let dk_cases = for_groups(ENCAP_DECAP_FILE, Some("decapsulationKeyCheck"), |k, t| { + by_k!(key_check_case, k, t, false) + }); + assert_eq!((ek_cases, dk_cases), (3 * 10, 3 * 10)); +} + +/// The tr1 groups of `function` whose `keyFormat` is `key_format`. +fn tr1_cases(function: &str, key_format: &str, check: fn(usize, &Value) -> usize) -> usize { + for_groups_where( + TR1_FILE, + |group| group["function"] == function && group["keyFormat"] == key_format, + check, + ) +} + +#[test] +fn tr1_encaps_internal_matches_acvp() { + let cases = tr1_cases("encapsulation", "none", |k, t| { + by_k!(encaps_internal_case, k, t) + }); + assert_eq!(cases, 3 * 25); +} + +#[test] +fn tr1_decaps_internal_matches_acvp_expanded_keys() { + let cases = tr1_cases("decapsulation", "expanded", |k, t| { + by_k!(decaps_internal_case, k, t) + }); + assert_eq!(cases, 3 * 10); +} + +/// A seed-form key is `d || z`; the expanded `dk` is what Algorithm 16 makes of +/// it, so the vector's `dk` must equal `keygen_internal(d, z)` and decapsulating +/// with that key must give the vector's `k`. +fn decaps_seed_case(t: &Value) -> usize { + let (ek, dk) = acvp::keygen_internal::(&seed(t, "d"), &seed(t, "z")); + assert_eq!(ek, hex(t, "ek"), "tcId {}", t["tcId"]); + assert_eq!(dk.to_vec(), hex(t, "dk"), "tcId {}", t["tcId"]); + let k = acvp::decaps_internal::(&dk, &hex(t, "c")).unwrap(); + assert_eq!(*k, hex(t, "k")[..], "tcId {}", t["tcId"]); + 1 +} + +#[test] +fn tr1_decaps_internal_matches_acvp_seed_keys() { + let cases = tr1_cases("decapsulation", "seed", |k, t| { + by_k!(decaps_seed_case, k, t) + }); + assert_eq!(cases, 3 * 10); +} + +#[test] +fn tr1_key_checks_match_acvp() { + let ek_cases = tr1_cases("encapsulationKeyCheck", "none", |k, t| { + by_k!(key_check_case, k, t, true) + }); + let dk_cases = tr1_cases("decapsulationKeyCheck", "expanded", |k, t| { + by_k!(key_check_case, k, t, false) + }); + assert_eq!((ek_cases, dk_cases), (3 * 10, 3 * 10)); +} diff --git a/src/mlkem/cctv_tests.rs b/src/mlkem/cctv_tests.rs new file mode 100644 index 0000000..eb483cd --- /dev/null +++ b/src/mlkem/cctv_tests.rs @@ -0,0 +1,421 @@ +//! ML-KEM against the C2SP CCTV vectors (repository C2SP/CCTV, directory +//! ML-KEM, fetched by `scripts/fetch_vectors.py` at the commit pinned in +//! `tests/vectors/manifest.json` and never committed). A missing file fails the +//! test: run the fetch script. +//! +//! What each set proves: +//! - `modulus/`: encapsulation keys with one coefficient in q..2^12-1; the +//! section 7.2 modulus check must reject every one, at every position. +//! - `unluckysample/`: a `d` whose `rho` makes SampleNTT (Algorithm 7) read +//! more than 575 bytes of the XOF for `A[0][0]`, so the refill path runs. +//! - `strcmp/`: a modified ciphertext whose first byte is zero and whose +//! re-encryption starts with the same zero byte, so a C string compare would +//! accept it. Decaps must return the implicit-rejection secret. +//! - the accumulated vectors: the README fixes a SHAKE128 stream of inputs and +//! the hash of all outputs of the pq-crystals reference, over 10 000 runs. +//! +//! The `unluckysample` files and the accumulated hashes were made before the final +//! standard: `(rho, sigma)` there is `G(d)` and the final one is `G(d || k)` +//! (FIPS 203 Algorithm 13 step 1, see `kpke::keygen`). So `draft_keygen` below +//! builds the key pair with the old derivation from `kpke::keygen_expanded`, +//! which is the rest of Algorithms 13 and 16 unchanged. The final derivation is +//! covered by the ACVP vectors. + +use super::check::{check_dk, check_ek, split_dk}; +use super::compress::MSG_BYTES; +use super::kem::{acvp, decaps, encaps}; +use super::kpke::{decrypt, encrypt, keygen_expanded}; +use super::params::{MlKem1024, MlKem512, MlKem768, ParamSet}; +use crate::kyber::error::KyberError; +use crate::kyber::fips202::{ + keccakf1600_statepermute, sha3_256, sha3_512, shake128_absorb_once, shake128_squeezeblocks, + shake256, KeccakState, SHAKE128_RATE, +}; +use crate::kyber::params::{KYBER_N, KYBER_Q, KYBER_SSBYTES, KYBER_SYMBYTES}; +use crate::kyber::symmetric::{xof_absorb_bytes, xof_squeeze_blocks, XOF_BLOCKBYTES}; +use std::collections::HashMap; +use std::io::Read; + +const CCTV_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/vectors/external/cctv/"); +const HEX_DIGITS_PER_BYTE: usize = 2; +const HEX_RADIX: u32 = 16; +const SEED_BYTES: usize = KYBER_SYMBYTES; +/// Bytes of the XOF stream SampleNTT needs for 256 coefficients in the unlucky +/// case or more (README, "Unlucky NTT sampling vector"). +const UNLUCKY_MIN_BYTES: usize = 575; +/// Bytes consumed by one pair of candidates d1, d2 (Algorithm 7 lines 5-7). +const PAIR_BYTES: usize = 3; +/// Bits per candidate, so a candidate is below 2^12 (Algorithm 7 lines 6-7). +const CANDIDATE_BITS: u32 = 12; +/// Bits of the middle byte that belong to d1 (the rest belong to d2). +const NIBBLE_BITS: u32 = CANDIDATE_BITS - u8::BITS; +/// Lanes of the Keccak state (FIPS 202 section 3.1: 1600 bits of 64). +const KECCAK_LANES: usize = 25; +const LANE_BYTES: usize = u64::BITS as usize / u8::BITS as usize; +/// A modulus-vector file has this many keys in addition to the sweep over +/// positions: two positions times two values, per polynomial (modulus.go). +const EDGE_VECTORS_PER_POLY: usize = 4; +/// Values q..2^12-1 swept by the modulus vectors. +const OVERFLOW_VALUES: usize = (1 << CANDIDATE_BITS) - KYBER_Q; + +/// Runs `$f::(name)` for the three parameter sets. +macro_rules! each_level { + ($f:ident) => { + $f::<{ MlKem512::K }>("ML-KEM-512"); + $f::<{ MlKem768::K }>("ML-KEM-768"); + $f::<{ MlKem1024::K }>("ML-KEM-1024"); + }; +} + +fn unhex(digits: &str) -> Vec { + (0..digits.len()) + .step_by(HEX_DIGITS_PER_BYTE) + .map(|i| u8::from_str_radix(&digits[i..i + HEX_DIGITS_PER_BYTE], HEX_RADIX).unwrap()) + .collect() +} + +fn read(path: &str) -> Vec { + let path = format!("{CCTV_DIR}{path}"); + std::fs::read(&path).unwrap_or_else(|e| panic!("{path}: {e}; run scripts/fetch_vectors.py")) +} + +/// A `name = hex` file with one record (the `unluckysample` and `strcmp` files). +fn record(path: &str) -> HashMap> { + let text = String::from_utf8(read(path)).expect("ASCII file"); + let mut fields = HashMap::new(); + for line in text.lines() { + let (name, value) = line.split_once(" = ").expect("`name = hex` line"); + assert!( + fields.insert(name.to_string(), unhex(value)).is_none(), + "{path}: repeated {name}" + ); + } + fields +} + +fn seed(fields: &HashMap>, name: &str) -> [u8; SEED_BYTES] { + fields[name].clone().try_into().expect("32-byte field") +} + +/// Bytes of the SHAKE128 stream for `rho || 0 || 0` that Algorithm 7 reads +/// before it has 256 coefficients below q. +fn sample_ntt_bytes(rho: &[u8]) -> usize { + let mut state = KeccakState::new(); + xof_absorb_bytes(&mut state, rho, 0, 0); + let mut stream = Vec::new(); + let (mut accepted, mut used) = (0, 0); + while accepted < KYBER_N { + if used + PAIR_BYTES > stream.len() { + let mut block = [0u8; XOF_BLOCKBYTES]; + xof_squeeze_blocks(&mut block, 1, &mut state); + stream.extend_from_slice(&block); + } + let c = &stream[used..used + PAIR_BYTES]; + used += PAIR_BYTES; + // Algorithm 7 lines 6-7. + let d1 = c[0] as usize + (1 << u8::BITS) * (c[1] as usize % (1 << NIBBLE_BITS)); + let d2 = (c[1] as usize >> NIBBLE_BITS) + (1 << NIBBLE_BITS) * c[2] as usize; + for d in [d1, d2] { + if d < KYBER_Q && accepted < KYBER_N { + accepted += 1; + } + } + } + used +} + +/// Algorithm 16 with `(rho, sigma) = G(d)`, as in the standard before it added `k`. +fn draft_keygen(d: &[u8; SEED_BYTES], z: &[u8; SEED_BYTES]) -> (Vec, Vec) { + let mut g = [0u8; 2 * SEED_BYTES]; + sha3_512(&mut g, d, d.len()); + let (rho, sigma) = g.split_at(SEED_BYTES); + let (ek, dk_pke) = keygen_expanded::(rho.try_into().unwrap(), sigma.try_into().unwrap()); + let mut h = [0u8; SEED_BYTES]; + sha3_256(&mut h, &ek, ek.len()); + let dk = [&dk_pke[..], &ek, &h, z].concat(); + (ek, dk) +} + +fn unlucky_level(name: &str) { + let f = record(&format!("unluckysample/{name}.txt")); + let (ek, dk, c) = (&f["ek"], &f["dk"], &f["c"]); + let (d, m) = (seed(&f, "d"), seed(&f, "m")); + let rho = &ek[ek.len() - SEED_BYTES..]; + + // Draft derivation of rho, not the final one; see the module comment. + let mut g = [0u8; 2 * SEED_BYTES]; + sha3_512(&mut g, &d, d.len()); + assert_eq!(g[..SEED_BYTES], *rho, "{name}: rho is G(d)"); + assert!( + sample_ntt_bytes(rho) > UNLUCKY_MIN_BYTES, + "{name}: not an unlucky rho" + ); + + check_ek::(ek).unwrap(); + check_dk::(dk).unwrap(); + let (k, ct) = acvp::encaps_internal::(ek, &m).unwrap(); + assert_eq!( + (k.to_vec(), &ct), + (f["K"].clone(), c), + "{name}: encaps_internal" + ); + assert_eq!(*decaps::(dk, c).unwrap(), f["K"][..], "{name}: decaps"); +} + +#[test] +fn unlucky_sampling_vectors() { + each_level!(unlucky_level); +} + +fn strcmp_level(name: &str) { + let f = record(&format!("strcmp/{name}.txt")); + let (dk, c) = (&f["dk"], &f["c"]); + assert_eq!(*decaps::(dk, c).unwrap(), f["K"][..], "{name}: decaps"); + + // The expected secret is the implicit-rejection one, J(z || c) (Algorithm 18 step 7). + let parts = split_dk::(dk).unwrap(); + let mut j = [0u8; KYBER_SSBYTES]; + let input = [parts.z, c].concat(); + shake256(&mut j, KYBER_SSBYTES, &input, input.len()); + assert_eq!(j[..], f["K"][..], "{name}: K is J(z || c)"); + + // Re-encrypt as Algorithm 18 steps 5-6 do; the strcmp trap is that the + // ciphertexts differ only after the first zero byte. + let m = decrypt::(parts.dk_pke, c).unwrap(); + let mut kr = [0u8; 2 * SEED_BYTES]; + let g_input: [u8; MSG_BYTES + SEED_BYTES] = [&m[..], parts.h].concat().try_into().unwrap(); + sha3_512(&mut kr, &g_input, g_input.len()); + let reencrypted = encrypt::(parts.ek, &m, kr[SEED_BYTES..].try_into().unwrap()).unwrap(); + let zero = c.iter().position(|&b| b == 0).expect("a zero byte in c"); + assert_eq!( + reencrypted[..=zero], + c[..=zero], + "{name}: equal up to the zero byte" + ); + assert_ne!(reencrypted, *c, "{name}: c is not the re-encryption"); +} + +#[test] +fn strcmp_vectors() { + each_level!(strcmp_level); +} + +fn modulus_level(name: &str) { + let mut text = String::new(); + flate2::read::GzDecoder::new(&read(&format!("modulus/{name}.txt.gz"))[..]) + .read_to_string(&mut text) + .unwrap(); + let mut keys = 0; + for line in text.lines() { + let ek = unhex(line); + assert_eq!( + ek.len(), + ParamSet::::EK_BYTES, + "{name}: key {keys} has the right length" + ); + assert!( + matches!(check_ek::(&ek), Err(KyberError::InvalidEncapsulationKey)), + "{name}: check_ek accepted key {keys}" + ); + assert!( + matches!(encaps::(&ek), Err(KyberError::InvalidEncapsulationKey)), + "{name}: encaps accepted key {keys}" + ); + keys += 1; + } + // modulus.go: four edge keys per polynomial, then a sweep that runs until + // every value and every position has been used. + let sweep = OVERFLOW_VALUES.max(K * KYBER_N); + assert_eq!( + keys, + EDGE_VECTORS_PER_POLY * K + sweep, + "{name}: number of keys" + ); +} + +#[test] +fn modulus_check_negatives() { + each_level!(modulus_level); +} + +/// The README of the pinned commit, which holds the hashes below. +fn readme() -> String { + String::from_utf8(read("README.md")).unwrap() +} + +/// Accumulated vectors, README "Accumulated pq-crystals vectors". The values +/// are the hashes printed there, for 10 000 and 1 000 000 runs. +const ACCUMULATED: [(&str, usize, &str); 6] = [ + ( + "ML-KEM-512", + 10_000, + "845913ea5a308b803c764a9ed8e9d814ca1fd9c82ba43c7b1e64b79c7a6ec8e4", + ), + ( + "ML-KEM-768", + 10_000, + "f7db260e1137a742e05fe0db9525012812b004d29040a5b606aad3d134b548d3", + ), + ( + "ML-KEM-1024", + 10_000, + "47ac888fe61544efc0518f46094b4f8a600965fc89822acb06dc7169d24f3543", + ), + ( + "ML-KEM-512", + 1_000_000, + "578eeaa1156848cbf7a15bafef963b4ccabe3308ddfb7dbdd20ad965f634e81d", + ), + ( + "ML-KEM-768", + 1_000_000, + "70090cc5842aad0ec43d5042c783fae9bc320c047b5dafcb6e134821db02384d", + ), + ( + "ML-KEM-1024", + 1_000_000, + "7ccc6d803739d3db3c5ce39c7130f459db32a199c6605e3be210e5a89d4c4b95", + ), +]; +/// Runs per parameter set unless `PYKYBER_CCTV_RUNS` says otherwise (the +/// million-run hash needs a release build: `cargo test --release`). +const DEFAULT_RUNS: usize = 10_000; +const RUNS_VARIABLE: &str = "PYKYBER_CCTV_RUNS"; +/// First bytes of the deterministic stream, as printed in the README. +const STREAM_START: [u8; 16] = [ + 0x7f, 0x9c, 0x2b, 0xa4, 0xe8, 0x8f, 0x82, 0x7d, 0x61, 0x60, 0x45, 0x50, 0x76, 0x05, 0x85, 0x3e, +]; +/// SHAKE128 output bytes of the final hash (32, as in the README). +const HASH_BYTES: usize = 32; +/// SHAKE128 domain separation byte (FIPS 202 section 6.2). +const SHAKE_SUFFIX: u8 = 0x1F; + +/// The README's deterministic RNG: one SHAKE128 instance over the empty string. +struct Stream { + state: KeccakState, + block: [u8; SHAKE128_RATE], + pos: usize, +} + +impl Stream { + fn new() -> Self { + let mut state = KeccakState::new(); + shake128_absorb_once(&mut state, &[], 0); + Stream { + state, + block: [0; SHAKE128_RATE], + pos: SHAKE128_RATE, + } + } + + fn take(&mut self, out: &mut [u8]) { + for byte in out.iter_mut() { + if self.pos == SHAKE128_RATE { + shake128_squeezeblocks(&mut self.block, 1, &mut self.state); + self.pos = 0; + } + *byte = self.block[self.pos]; + self.pos += 1; + } + } + + fn seed(&mut self) -> [u8; SEED_BYTES] { + let mut out = [0u8; SEED_BYTES]; + self.take(&mut out); + out + } +} + +/// An incremental SHAKE128 that absorbs byte strings one after another. +struct Hasher { + s: [u64; KECCAK_LANES], + pos: usize, +} + +impl Hasher { + fn new() -> Self { + Hasher { + s: [0; KECCAK_LANES], + pos: 0, + } + } + + fn absorb(&mut self, data: &[u8]) { + for &byte in data { + self.s[self.pos / LANE_BYTES] ^= + (byte as u64) << (u8::BITS as usize * (self.pos % LANE_BYTES)); + self.pos += 1; + if self.pos == SHAKE128_RATE { + keccakf1600_statepermute(&mut self.s); + self.pos = 0; + } + } + } + + fn finish(mut self) -> [u8; HASH_BYTES] { + self.s[self.pos / LANE_BYTES] ^= + (SHAKE_SUFFIX as u64) << (u8::BITS as usize * (self.pos % LANE_BYTES)); + self.s[(SHAKE128_RATE - 1) / LANE_BYTES] ^= 1 << (u64::BITS - 1); + keccakf1600_statepermute(&mut self.s); + let lanes = self.s.iter().flat_map(|lane| lane.to_le_bytes()); + let mut out = [0u8; HASH_BYTES]; + for (byte, value) in out.iter_mut().zip(lanes) { + *byte = value; + } + out + } +} + +fn accumulated_level(name: &str) { + let runs = std::env::var(RUNS_VARIABLE).map_or(DEFAULT_RUNS, |v| v.parse().unwrap()); + let expected = ACCUMULATED + .iter() + .find(|(n, r, _)| *n == name && *r == runs) + .unwrap_or_else(|| panic!("no hash for {runs} runs of {name}")) + .2; + assert!( + readme().contains(expected), + "{name}: hash not in the pinned README" + ); + + let mut stream = Stream::new(); + let mut hasher = Hasher::new(); + for run in 0..runs { + let (d, z, m) = (stream.seed(), stream.seed(), stream.seed()); + let mut random_ct = vec![0u8; ParamSet::::CT_BYTES]; + stream.take(&mut random_ct); + let (ek, dk) = draft_keygen::(&d, &z); + let (k, ct) = acvp::encaps_internal::(&ek, &m).unwrap(); + let decapsulated = acvp::decaps_internal::(&dk, &ct).unwrap(); + assert_eq!(*k, *decapsulated, "{name}: run {run} does not decapsulate"); + let rejected = acvp::decaps_internal::(&dk, &random_ct).unwrap(); + for part in [&ek[..], &dk[..], &ct[..], &k[..], &rejected[..]] { + hasher.absorb(part); + } + } + let hash: String = hasher.finish().iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!(hash, expected, "{name}: accumulated hash over {runs} runs"); +} + +#[test] +fn rng_stream_starts_as_in_the_readme() { + let mut first = [0u8; STREAM_START.len()]; + Stream::new().take(&mut first); + assert_eq!(first, STREAM_START); +} + +// One test per parameter set so the harness runs them in parallel. +#[test] +fn accumulated_vectors_512() { + accumulated_level::<{ MlKem512::K }>("ML-KEM-512"); +} + +#[test] +fn accumulated_vectors_768() { + accumulated_level::<{ MlKem768::K }>("ML-KEM-768"); +} + +#[test] +fn accumulated_vectors_1024() { + accumulated_level::<{ MlKem1024::K }>("ML-KEM-1024"); +} diff --git a/src/mlkem/check.rs b/src/mlkem/check.rs new file mode 100644 index 0000000..bab06f5 --- /dev/null +++ b/src/mlkem/check.rs @@ -0,0 +1,222 @@ +//! Input checks of FIPS 203 sections 7.2 and 7.3, plus the layout of the +//! decapsulation key (Algorithm 16, step 3). +//! +//! Every check works on public data: the length, the encoded `t` and `rho` of +//! the encapsulation key, the stored `H(ek)`. None of them reads `dk_PKE` or +//! `z`, so the plain comparisons below are not a timing channel for secrets. +//! They run before any secret-dependent work, and a failure is a typed error. + +use super::params::ParamSet; +use super::polyvec::PolyVec; +use crate::kyber::error::KyberError; +use crate::kyber::fips202::sha3_256; +use crate::kyber::params::KYBER_SYMBYTES; + +/// H(ek) and z are 32 bytes each. +pub(crate) const HASH_BYTES: usize = KYBER_SYMBYTES; + +pub(crate) fn check_len( + param: &'static str, + expected: usize, + actual: usize, +) -> Result<(), KyberError> { + if expected == actual { + Ok(()) + } else { + Err(KyberError::invalid_input(param, expected, actual)) + } +} + +/// The four fields of `dk = dk_PKE || ek || H(ek) || z` (Algorithm 16 step 3, +/// Algorithm 18 steps 1-4). `dk_PKE` and `z` are secret. +pub(crate) struct DkParts<'a> { + pub dk_pke: &'a [u8], + pub ek: &'a [u8], + pub h: &'a [u8], + pub z: &'a [u8], +} + +/// Splits a decapsulation key after checking its length (section 7.3, check 2). +pub(crate) fn split_dk(dk: &[u8]) -> Result, KyberError> { + check_len("dk", ParamSet::::DK_BYTES, dk.len())?; + let (dk_pke, rest) = dk.split_at(ParamSet::::DK_PKE_BYTES); + let (ek, rest) = rest.split_at(ParamSet::::EK_BYTES); + let (h, z) = rest.split_at(HASH_BYTES); + Ok(DkParts { dk_pke, ek, h, z }) +} + +/// Ciphertext type check (section 7.3, check 1), required on every decapsulation. +pub(crate) fn check_ct(c: &[u8]) -> Result<(), KyberError> { + check_len("c", ParamSet::::CT_BYTES, c.len()) +} + +/// Encapsulation key check (section 7.2): length, then the modulus check +/// `ByteEncode12(ByteDecode12(ek[0:384k])) == ek[0:384k]` (equation 7.1). +pub(crate) fn check_ek(ek: &[u8]) -> Result<(), KyberError> { + check_len("ek", ParamSet::::EK_BYTES, ek.len())?; + let t = &ek[..ParamSet::::DK_PKE_BYTES]; + // `from_bytes` reduces mod q, so a coefficient >= q does not survive the round trip. + let mut test = vec![0u8; t.len()]; + PolyVec::::from_bytes(t).to_bytes(&mut test); + if test == t { + Ok(()) + } else { + Err(KyberError::InvalidEncapsulationKey) + } +} + +/// Decapsulation key check (section 7.3): length, then the hash check +/// `H(dk[384k : 768k+32]) == dk[768k+32 : 768k+64]` (equation 7.2). +pub(crate) fn check_dk(dk: &[u8]) -> Result<(), KyberError> { + let parts = split_dk::(dk)?; + let mut test = [0u8; HASH_BYTES]; + sha3_256(&mut test, parts.ek, parts.ek.len()); + if test == parts.h { + Ok(()) + } else { + Err(KyberError::InvalidDecapsulationKey) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::kyber::params::{KYBER_POLYBYTES, KYBER_Q}; + use crate::mlkem::kpke; + use crate::mlkem::params::{MlKem1024, MlKem512, MlKem768}; + + /// Bits in one coefficient of ByteEncode12. + const COEFF_BITS: u32 = 12; + /// The part of a 12-bit coefficient that lives in the second byte. + const SECOND_BYTE_BITS: u8 = ((1u16 << (COEFF_BITS - u8::BITS)) - 1) as u8; + + /// Overwrites the first 12-bit coefficient (bytes 0 and the low nibble of byte 1). + fn set_first_coeff(ek: &mut [u8], value: u16) { + ek[0] = value as u8; + ek[1] = (ek[1] & !SECOND_BYTE_BITS) | (value >> u8::BITS) as u8; + } + + fn keys() -> (Vec, Vec) { + let (ek, dk_pke) = kpke::keygen::(&[K as u8; KYBER_SYMBYTES]); + let mut h = [0u8; HASH_BYTES]; + sha3_256(&mut h, &ek, ek.len()); + let dk = [&dk_pke[..], &ek, &h, &[0x5a; KYBER_SYMBYTES]].concat(); + (ek, dk) + } + + fn each_set(check: fn(usize)) { + check(MlKem512::K); + check(MlKem768::K); + check(MlKem1024::K); + } + + macro_rules! per_set { + ($f:ident, $k:expr) => { + match $k { + MlKem512::K => $f::<{ MlKem512::K }>(), + MlKem768::K => $f::<{ MlKem768::K }>(), + _ => $f::<{ MlKem1024::K }>(), + } + }; + } + + fn ek_checks() { + let (ek, _) = keys::(); + assert_eq!(check_ek::(&ek), Ok(())); + // Length: one byte short, one byte long, empty. + let bad = |actual| KyberError::invalid_input("ek", ParamSet::::EK_BYTES, actual); + assert_eq!(check_ek::(&ek[1..]), Err(bad(ek.len() - 1))); + assert_eq!( + check_ek::(&[&ek[..], &[0]].concat()), + Err(bad(ek.len() + 1)) + ); + assert_eq!(check_ek::(&[]), Err(bad(0))); + // Modulus: q - 1 is the largest canonical value, q is the smallest bad one. + let mut edited = ek.clone(); + set_first_coeff(&mut edited, KYBER_Q as u16 - 1); + assert_eq!(check_ek::(&edited), Ok(())); + for value in [KYBER_Q as u16, KYBER_Q as u16 + 1, (1 << COEFF_BITS) - 1] { + set_first_coeff(&mut edited, value); + assert_eq!( + check_ek::(&edited), + Err(KyberError::InvalidEncapsulationKey) + ); + } + // A bad coefficient in the last polynomial is found as well. + let mut late = ek.clone(); + let last = ParamSet::::DK_PKE_BYTES - KYBER_POLYBYTES; + set_first_coeff(&mut late[last..], KYBER_Q as u16); + assert_eq!( + check_ek::(&late), + Err(KyberError::InvalidEncapsulationKey) + ); + // rho (the trailing seed) is not range-checked: any 32 bytes are valid. + let mut seed = ek.clone(); + *seed.last_mut().unwrap() ^= u8::MAX; + assert_eq!(check_ek::(&seed), Ok(())); + } + + #[test] + fn encapsulation_key_checks_for_all_sets() { + each_set(|k| per_set!(ek_checks, k)); + } + + fn dk_checks() { + let (ek, dk) = keys::(); + assert_eq!(check_dk::(&dk), Ok(())); + let bad = |actual| KyberError::invalid_input("dk", ParamSet::::DK_BYTES, actual); + assert_eq!(check_dk::(&dk[1..]), Err(bad(dk.len() - 1))); + assert_eq!( + check_dk::(&[&dk[..], &[0]].concat()), + Err(bad(dk.len() + 1)) + ); + assert_eq!(check_dk::(&[]), Err(bad(0))); + // A stored hash with one flipped bit. + let h_at = ParamSet::::DK_PKE_BYTES + ParamSet::::EK_BYTES; + let mut wrong_h = dk.clone(); + wrong_h[h_at] ^= 1; + assert_eq!( + check_dk::(&wrong_h), + Err(KyberError::InvalidDecapsulationKey) + ); + // An embedded ek with one flipped bit no longer matches the stored hash. + let mut wrong_ek = dk.clone(); + wrong_ek[ParamSet::::DK_PKE_BYTES] ^= 1; + assert_eq!( + check_dk::(&wrong_ek), + Err(KyberError::InvalidDecapsulationKey) + ); + // dk_PKE and z are not covered by the hash check (section 7.3). + let mut secret_part = dk.clone(); + secret_part[0] ^= 1; + *secret_part.last_mut().unwrap() ^= 1; + assert_eq!(check_dk::(&secret_part), Ok(())); + // The split follows the layout dk_PKE || ek || H(ek) || z. + let parts = split_dk::(&dk).unwrap(); + assert_eq!(parts.ek, &ek[..]); + assert_eq!(parts.dk_pke.len(), ParamSet::::DK_PKE_BYTES); + assert_eq!((parts.h.len(), parts.z.len()), (HASH_BYTES, HASH_BYTES)); + } + + #[test] + fn decapsulation_key_checks_for_all_sets() { + each_set(|k| per_set!(dk_checks, k)); + } + + fn ct_checks() { + let ok = vec![0u8; ParamSet::::CT_BYTES]; + assert_eq!(check_ct::(&ok), Ok(())); + let bad = |actual| KyberError::invalid_input("c", ParamSet::::CT_BYTES, actual); + assert_eq!(check_ct::(&ok[1..]), Err(bad(ok.len() - 1))); + assert_eq!( + check_ct::(&[&ok[..], &[0]].concat()), + Err(bad(ok.len() + 1)) + ); + assert_eq!(check_ct::(&[]), Err(bad(0))); + } + + #[test] + fn ciphertext_check_for_all_sets() { + each_set(|k| per_set!(ct_checks, k)); + } +} diff --git a/src/mlkem/compress.rs b/src/mlkem/compress.rs new file mode 100644 index 0000000..db4e2f8 --- /dev/null +++ b/src/mlkem/compress.rs @@ -0,0 +1,272 @@ +//! Compress_d / Decompress_d with ByteEncode_d / ByteDecode_d for the ML-KEM +//! compression widths, and the message codec. +//! +//! The legacy `polyvec_compress_1024` reuses the d = 4/5/10 multiplier +//! (10321345 >> 35), which is off by one at the residues 1875 and 3125 when +//! d = 11. Legacy output is frozen, so ML-KEM gets its own routine, generic over +//! `d`, which also covers d = 1 (messages), 4, 5 and 10. + +use crate::kyber::params::{KYBER_N, KYBER_Q}; +use crate::kyber::poly::Poly; +use core::hint::black_box; + +/// Shift that moves the sign bit of an `i16` to bit 0. +const SIGN_SHIFT: u32 = i16::BITS - 1; +/// (q - 1) / 2, the rounding offset: q is odd, so adding it before the floor +/// rounds halves up. +const HALF_Q: u32 = (KYBER_Q as u32 - 1) / 2; +/// ceil(2^RECIPROCAL_SHIFT / q), so that `x * RECIPROCAL >> RECIPROCAL_SHIFT` +/// is `x / q` for the inputs below (verified over all of Z_q, for every `d` +/// used by ML-KEM, by the tests). +const RECIPROCAL: u64 = 645_084; +const RECIPROCAL_SHIFT: u32 = 31; + +/// Bytes of one polynomial compressed to `d` bits per coefficient +/// (FIPS 203 Algorithm 5: 256 coefficients of `d` bits). +pub(crate) const fn compressed_bytes(d: u32) -> usize { + KYBER_N * d as usize / u8::BITS as usize +} + +/// Compress_d then ByteEncode_d (FIPS 203 (4.7), Algorithm 5). +/// +/// `Compress_d(x) = round(2^d * x / q) mod 2^d`, with halves rounding up. +/// For 0 <= x < q this equals +/// `((x << d) + HALF_Q) * RECIPROCAL >> RECIPROCAL_SHIFT`, checked over every +/// x in the unit test below. Coefficients may be negative representatives in +/// (-q, q). There is no division, and no branch or index depends on the data; +/// the only loop condition counts bits, which is public. `d` is a public +/// parameter in 1..=11. `r` must hold `compressed_bytes(d)` bytes. +pub(crate) fn poly_compress(r: &mut [u8], a: &Poly, d: u32) { + debug_assert_eq!(r.len(), compressed_bytes(d)); + let d_mask: u32 = (1 << d) - 1; + // Bit accumulator: little-endian bit order, as in Algorithm 5. + let mut acc = 0u32; + let mut acc_bits = 0u32; + let mut idx = 0usize; + for &coeff in a.coeffs.iter() { + let mut u = coeff; + u += (u >> SIGN_SHIFT) & KYBER_Q as i16; + let scaled = ((u as u32) << d) + HALF_Q; + let compressed = ((scaled as u64 * RECIPROCAL) >> RECIPROCAL_SHIFT) as u32 & d_mask; + acc |= compressed << acc_bits; + acc_bits += d; + while acc_bits >= u8::BITS { + r[idx] = acc as u8; + idx += 1; + acc >>= u8::BITS; + acc_bits -= u8::BITS; + } + } +} + +/// Bytes of a message m: one bit per coefficient (Algorithm 5 with d = 1). +pub(crate) const MSG_BYTES: usize = compressed_bytes(1); +/// Decompress_1(1) = round(q / 2) = ceil(q / 2) (FIPS 203 (4.8) with d = 1). +const DECOMPRESSED_ONE: i16 = KYBER_Q.div_ceil(2) as i16; + +/// ByteDecode_d then Decompress_d (FIPS 203 Algorithm 6, (4.8)). +/// +/// `Decompress_d(y) = round(q * y / 2^d)`, with halves rounding up, which is +/// `(y * q + 2^(d-1)) >> d` for `0 <= y < 2^d`: exact, and no division. `d` is +/// a public parameter in 1..=11. `a` must hold `compressed_bytes(d)` bytes; a +/// shorter slice reads as zeros. +pub(crate) fn poly_decompress(r: &mut Poly, a: &[u8], d: u32) { + debug_assert_eq!(a.len(), compressed_bytes(d)); + let d_mask: u32 = (1 << d) - 1; + let mut bytes = a.iter(); + // Bit accumulator: little-endian bit order, as in Algorithm 6. + let mut acc = 0u32; + let mut acc_bits = 0u32; + for coeff in r.coeffs.iter_mut() { + while acc_bits < d { + acc |= (*bytes.next().unwrap_or(&0) as u32) << acc_bits; + acc_bits += u8::BITS; + } + let y = acc & d_mask; + acc >>= d; + acc_bits -= d; + *coeff = ((y * KYBER_Q as u32 + (1 << (d - 1))) >> d) as i16; + } +} + +/// `Decompress_1(ByteDecode_1(m))` (FIPS 203 Algorithm 14, step 20). +/// +/// `m` is secret. Each bit becomes an all-zeros or all-ones mask that goes +/// through `black_box` before it is applied, so the compiler cannot see that +/// it is 0 or -1 and turn the selection into a branch (the pattern of +/// CVE-2024-37880 in the legacy `poly_frommsg`). `black_box` is a best-effort +/// barrier, which is why the release assembly is checked in WP-8. +pub(crate) fn poly_from_msg(r: &mut Poly, m: &[u8; MSG_BYTES]) { + for (i, coeff) in r.coeffs.iter_mut().enumerate() { + // Bit i of the message, little-endian within each byte (Algorithm 4, BytesToBits). + let bit = ((m[i / u8::BITS as usize] >> (i % u8::BITS as usize)) & 1) as i16; + *coeff = black_box(bit.wrapping_neg()) & DECOMPRESSED_ONE; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::kyber::params::KYBER_1024_K; + use crate::kyber::polyvec::{polyvec_compress_1024, Polyvec1024}; + + const Q: i64 = KYBER_Q as i64; + /// The residues where the legacy d = 11 routine is off by one. + const LEGACY_DEVIATING: [i16; 2] = [1875, 3125]; + /// Polynomials in a Kyber-1024 vector. + const K: usize = KYBER_1024_K; + /// Compression widths ML-KEM uses: the message (1), dv (4, 5) and du (10, 11). + const D_MESSAGE: u32 = 1; + const D_V_512_768: u32 = 4; + const D_V_1024: u32 = 5; + const D_U_512_768: u32 = 10; + const D_U_1024: u32 = 11; + const ALL_D: [u32; 5] = [D_MESSAGE, D_V_512_768, D_V_1024, D_U_512_768, D_U_1024]; + + /// FIPS 203 (4.7) in exact integers: floor((2^(d+1) * x + q) / 2q) mod 2^d. + fn spec_compress(d: u32, x: i64) -> i64 { + ((2 * (x << d) + Q) / (2 * Q)) & ((1 << d) - 1) + } + + /// Algorithm 5, little-endian bit packing of 256 d-bit values. + fn spec_byte_encode(d: u32, f: &[i64]) -> Vec { + let d = d as usize; + let mut out = vec![0u8; KYBER_N * d / 8]; + for (i, &v) in f.iter().enumerate() { + for j in 0..d { + let k = i * d + j; + out[k / 8] |= (((v >> j) & 1) as u8) << (k % 8); // bit k of the stream + } + } + out + } + + fn constant_poly(x: i16) -> Poly { + let mut p = Poly::new(); + p.coeffs = [x; KYBER_N]; + p + } + + fn exact_for_every_residue(d: u32) { + // All of Z_q, and every negative representative -(q-1)..=-1 that the + // conditional add inside the routine maps back into Z_q. + for x in (0..Q as i16).chain((1..Q as i16).map(|x| x - Q as i16)) { + let mut got = vec![0u8; compressed_bytes(d)]; + poly_compress(&mut got, &constant_poly(x), d); + let residue = (x as i64).rem_euclid(Q); + let want = spec_byte_encode(d, &[spec_compress(d, residue); KYBER_N]); + assert_eq!(got, want, "d = {d}, x = {x}"); + } + } + + #[test] + fn exact_for_every_residue_and_negative_representative() { + for d in ALL_D { + exact_for_every_residue(d); + } + } + + #[test] + fn packs_distinct_coefficients_in_order() { + let mut p = Poly::new(); + for (i, c) in p.coeffs.iter_mut().enumerate() { + // Arbitrary fill: distinct-looking values across the whole range. + *c = ((i * 13 + 7) % KYBER_Q) as i16; + } + let mut got = vec![0u8; compressed_bytes(D_U_1024)]; + poly_compress(&mut got, &p, D_U_1024); + let f: Vec = p + .coeffs + .iter() + .map(|&x| spec_compress(D_U_1024, x as i64)) + .collect(); + assert_eq!(got, spec_byte_encode(D_U_1024, &f)); + } + + #[test] + fn d11_differs_from_legacy_only_at_the_two_legacy_residues() { + let bytes = compressed_bytes(D_U_1024); + for x in 0..KYBER_Q as i16 { + let mut ours = vec![0u8; bytes]; + poly_compress(&mut ours, &constant_poly(x), D_U_1024); + let mut v = Polyvec1024::new(); + v.vec[0] = constant_poly(x); + let mut legacy = vec![0u8; K * bytes]; + polyvec_compress_1024(&mut legacy, v); + let same = ours[..] == legacy[..bytes]; + assert_eq!(same, !LEGACY_DEVIATING.contains(&x), "x = {x}"); + } + } + + /// FIPS 203 (4.8) in exact integers: floor((2 * q * y + 2^d) / 2^(d+1)). + fn spec_decompress(d: u32, y: i64) -> i64 { + (2 * Q * y + (1 << d)) / (1 << (d + 1)) + } + + fn decompress_matches_spec(d: u32) { + for y in 0..1i64 << d { + let packed = spec_byte_encode(d, &[y; KYBER_N]); + let mut got = Poly::new(); + poly_decompress(&mut got, &packed, d); + assert_eq!( + got.coeffs, + [spec_decompress(d, y) as i16; KYBER_N], + "d = {d}, y = {y}" + ); + } + } + + #[test] + fn decompress_is_exact_for_every_input() { + for d in ALL_D { + decompress_matches_spec(d); + } + } + + #[test] + fn decompress_reads_distinct_values_in_order() { + let ys: Vec = (0..KYBER_N as i64) + .map(|i| (i * 37 + 5) % (1 << D_U_1024)) + .collect(); + let mut got = Poly::new(); + poly_decompress(&mut got, &spec_byte_encode(D_U_1024, &ys), D_U_1024); + let want: Vec = ys + .iter() + .map(|&y| spec_decompress(D_U_1024, y) as i16) + .collect(); + assert_eq!(got.coeffs.to_vec(), want); + } + + #[test] + fn compress_inverts_decompress() { + // FIPS 203 section 4.2.1: Compress_d(Decompress_d(y)) = y for d < 12. + for d in ALL_D { + let ys: Vec = (0..KYBER_N as i64).map(|i| i * 29 % (1 << d)).collect(); + let packed = spec_byte_encode(d, &ys); + let mut p = Poly::new(); + poly_decompress(&mut p, &packed, d); + let mut again = vec![0u8; compressed_bytes(d)]; + poly_compress(&mut again, &p, d); + assert_eq!(again, packed, "d = {d}"); + } + } + + #[test] + fn message_bits_map_to_zero_and_ceil_half_q() { + let mut m = [0u8; MSG_BYTES]; + for (i, b) in m.iter_mut().enumerate() { + *b = (i as u8).wrapping_mul(0x9d) ^ 0x5a; + } + let mut p = Poly::new(); + poly_from_msg(&mut p, &m); + for (i, &c) in p.coeffs.iter().enumerate() { + let bit = (m[i / 8] >> (i % 8)) & 1; + // 1665 = (3329 + 1) / 2, written out so the test does not reuse the constant. + assert_eq!(c, if bit == 1 { 1665 } else { 0 }, "i = {i}"); + } + // Compress_1 returns the message, as K-PKE.Decrypt does. + let mut back = [0u8; MSG_BYTES]; + poly_compress(&mut back, &p, D_MESSAGE); + assert_eq!(back, m); + } +} diff --git a/src/mlkem/ct.rs b/src/mlkem/ct.rs new file mode 100644 index 0000000..6cc8321 --- /dev/null +++ b/src/mlkem/ct.rs @@ -0,0 +1,121 @@ +//! Constant-time selection for the implicit rejection of ML-KEM.Decaps_internal +//! (FIPS 203 Algorithm 18, steps 9-11). +//! +//! `c != c'` is a secret (it tells an attacker whether a chosen ciphertext was +//! well formed), so it is neither returned nor stored: `reject_on_mismatch` +//! turns it into a byte mask, applies it and overwrites it before returning. +//! There is no branch on the comparison and no early exit from the loops. +//! +//! The standard library has no constant-time guarantee. `black_box` is an +//! optimization barrier that keeps the compiler from proving what the +//! accumulator or the mask is and turning the select back into a branch (the +//! pattern behind CVE-2024-37880, "Clangover"). It is best effort, so the release +//! assembly is checked (see the PR for WP-3 and the gate of WP-8). This is not +//! `kyber::verify`, which trusts a caller-supplied length. + +use crate::kyber::params::KYBER_SSBYTES; +use core::hint::black_box; +use zeroize::Zeroize; + +/// Shift that moves the top bit of a `u16` to bit 0. +const FLAG_SHIFT: u32 = u16::BITS - 1; + +/// Overwrites `k` with `k_bar` if `a` and `b` differ in length or content, and +/// leaves it alone if they are equal (Algorithm 18, steps 9-11, where `a = c`, +/// `b = c'`, `k = K'` and `k_bar = J(z || c)`). +/// +/// The lengths of `a` and `b` are public. Differing lengths count as a +/// mismatch, so a caller bug fails closed instead of comparing a prefix. +pub(crate) fn reject_on_mismatch( + k: &mut [u8; KYBER_SSBYTES], + k_bar: &[u8; KYBER_SSBYTES], + a: &[u8], + b: &[u8], +) { + // OR of all byte differences: zero exactly when the slices are equal. + let mut diff = u8::from(a.len() != b.len()); + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + + // bit = 1 if diff != 0 else 0. For diff in 1..=255, `-diff` as a u16 has + // its top bit set; for 0 both terms are 0. + let mut wide = u16::from(black_box(diff)); + let mut bit = ((wide | wide.wrapping_neg()) >> FLAG_SHIFT) as u8; + // 0x00 (keep K') or 0xff (take K-bar). + let mut mask = black_box(black_box(bit).wrapping_neg()); + + for (ki, kb) in k.iter_mut().zip(k_bar.iter()) { + *ki ^= mask & (*ki ^ *kb); + } + + // The reject flag shall be destroyed (FIPS 203 section 6.3). + diff.zeroize(); + wide.zeroize(); + bit.zeroize(); + mask.zeroize(); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mlkem::params::{MlKem1024, MlKem512, MlKem768}; + + const K_PRIME: [u8; KYBER_SSBYTES] = [0x11; KYBER_SSBYTES]; + const K_BAR: [u8; KYBER_SSBYTES] = [0xee; KYBER_SSBYTES]; + + fn run(a: &[u8], b: &[u8]) -> [u8; KYBER_SSBYTES] { + let mut k = K_PRIME; + reject_on_mismatch(&mut k, &K_BAR, a, b); + k + } + + #[test] + fn equal_inputs_keep_k_prime() { + for len in [ + 0, + 1, + MlKem512::CT_BYTES, + MlKem768::CT_BYTES, + MlKem1024::CT_BYTES, + ] { + let c: Vec = (0..len).map(|i| i as u8).collect(); + assert_eq!(run(&c, &c.clone()), K_PRIME, "len {len}"); + } + } + + #[test] + fn any_single_bit_difference_selects_k_bar() { + let c: Vec = (0..MlKem768::CT_BYTES).map(|i| (i * 7) as u8).collect(); + for pos in 0..c.len() { + for bit in 0..u8::BITS { + let mut other = c.clone(); + other[pos] ^= 1 << bit; + assert_eq!(run(&c, &other), K_BAR, "byte {pos} bit {bit}"); + } + } + } + + #[test] + fn every_byte_difference_selects_k_bar() { + // The accumulator covers all 255 nonzero byte values, not only one bit. + for delta in 1..=u8::MAX { + assert_eq!(run(&[0], &[delta]), K_BAR, "delta {delta}"); + } + } + + #[test] + fn differing_lengths_select_k_bar() { + let c = [7u8; 16]; + assert_eq!(run(&c, &c[..15]), K_BAR); + assert_eq!(run(&c[..15], &c), K_BAR); + assert_eq!(run(&c, &[]), K_BAR); + } + + #[test] + fn k_bar_equal_to_k_prime_is_harmless() { + let mut k = K_PRIME; + reject_on_mismatch(&mut k, &K_PRIME, &[1], &[2]); + assert_eq!(k, K_PRIME); + } +} diff --git a/src/mlkem/differential_tests.rs b/src/mlkem/differential_tests.rs new file mode 100644 index 0000000..a7b4824 --- /dev/null +++ b/src/mlkem/differential_tests.rs @@ -0,0 +1,221 @@ +//! Differential test against RustCrypto `ml-kem` 0.3.3 (a dev-dependency, FIPS +//! 203 final, written independently of this crate; no code is shared). +//! +//! Random inputs come from a fixed-seed generator, so a failure reproduces and +//! the case number in the message says which input it was. For every parameter +//! set, per round: +//! - KeyGen from a seed `d || z`: same `ek`, same expanded `dk`. +//! - Encaps with a given `m`: same `c` and `K`. +//! - Decaps of the valid ciphertext, of one with a flipped bit and of a random +//! one: same `K` (the last two are the implicit-rejection path). +//! - Decaps with a `dk` whose `dk_PKE` or `z` bytes were changed. +//! - The key checks: a random byte of `ek` or `dk` is replaced and both +//! implementations must agree on whether the key is accepted (section 7.2 +//! modulus check, section 7.3 hash check), plus a coefficient forced into +//! q..2^12-1. + +use super::check::{check_dk, check_ek}; +use super::kem::acvp; +use super::params::{MlKem1024, MlKem512, MlKem768, ParamSet}; +use crate::kyber::params::{KYBER_N, KYBER_Q, KYBER_SYMBYTES}; +use ml_kem::array::Array; +use ml_kem::kem::{Decapsulate, KeyExport}; +#[allow(deprecated)] +use ml_kem::ExpandedKeyEncoding; +use ml_kem::{FromSeed, TryKeyInit}; + +/// Rounds per parameter set; each round draws fresh seeds, `m` and mutations. +const ROUNDS: usize = 200; +/// Seed of the generator below. +const PRNG_SEED: u64 = 0x2545_f491_4f6c_dd1d; +/// Bits in the 12-bit coefficient encoding of the keys (ByteEncode12). +const COEFFICIENT_BITS: u32 = 12; +const SEED_BYTES: usize = KYBER_SYMBYTES; +const BYTE_BITS: usize = u8::BITS as usize; + +/// Shifts of Marsaglia's xorshift64 as used in xorshift64* (Vigna). +const XORSHIFT_RIGHT_1: u32 = 12; +const XORSHIFT_LEFT: u32 = 25; +const XORSHIFT_RIGHT_2: u32 = 27; +/// Output multiplier of xorshift64* (Vigna). +const OUTPUT_MULTIPLIER: u64 = 0x2545_f491_4f6c_dd1d; + +/// xorshift64*, enough for reproducible test inputs. +struct Prng(u64); + +impl Prng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 >> XORSHIFT_RIGHT_1; + self.0 ^= self.0 << XORSHIFT_LEFT; + self.0 ^= self.0 >> XORSHIFT_RIGHT_2; + self.0.wrapping_mul(OUTPUT_MULTIPLIER) + } + + fn below(&mut self, bound: usize) -> usize { + (self.next() >> u32::BITS) as usize % bound + } + + fn bytes(&mut self) -> [u8; N] { + let mut out = [0u8; N]; + out.iter_mut() + .for_each(|b| *b = (self.next() >> u32::BITS) as u8); + out + } + + fn vec(&mut self, len: usize) -> Vec { + (0..len).map(|_| (self.next() >> u32::BITS) as u8).collect() + } +} + +/// Sets the 12-bit coefficient number `index` of an encoded polynomial vector. +fn set_coefficient(encoded: &mut [u8], index: usize, value: u16) { + let first_bit = index * COEFFICIENT_BITS as usize; + for bit in 0..COEFFICIENT_BITS as usize { + let (byte, shift) = ((first_bit + bit) / BYTE_BITS, (first_bit + bit) % BYTE_BITS); + encoded[byte] = (encoded[byte] & !(1 << shift)) | ((((value >> bit) & 1) as u8) << shift); + } +} + +macro_rules! differential_test { + ($name:ident, $params:ident, $module:ident, $reference:ident) => { + #[test] + fn $name() { + use ml_kem::$module::{Ciphertext, DecapsulationKey, EncapsulationKey}; + const K: usize = $params::K; + let mut rng = Prng(PRNG_SEED); + // dk = dk_PKE || ek || H(ek) || z (Algorithm 16 step 3), and ek = t || rho. + let t_bytes = ParamSet::::DK_PKE_BYTES; + let z_start = ParamSet::::DK_BYTES - SEED_BYTES; + + for round in 0..ROUNDS { + let (d, z, m) = ( + rng.bytes::(), + rng.bytes::(), + rng.bytes::(), + ); + + // KeyGen. + let seed = Array::try_from([&d[..], &z[..]].concat().as_slice()).unwrap(); + let (ref_dk, ref_ek) = ml_kem::$reference::from_seed(&seed); + let (ek, dk) = acvp::keygen_internal::(&d, &z); + assert_eq!( + ek.as_slice(), + ref_ek.to_bytes().as_slice(), + "round {round}: ek" + ); + #[allow(deprecated)] + let ref_expanded = ref_dk.to_expanded_bytes(); + assert_eq!(dk.as_slice(), ref_expanded.as_slice(), "round {round}: dk"); + + // Encaps. + let (k, c) = acvp::encaps_internal::(&ek, &m).unwrap(); + let (ref_c, ref_k) = ref_ek.encapsulate_deterministic(&Array::from(m)); + assert_eq!(c.as_slice(), ref_c.as_slice(), "round {round}: c"); + assert_eq!(k.as_slice(), ref_k.as_slice(), "round {round}: K"); + + // Decaps: valid, one flipped bit, random. + let mut flipped = c.clone(); + flipped[rng.below(c.len())] ^= 1 << rng.below(BYTE_BITS); + for (label, input) in [ + ("valid", c.clone()), + ("flipped", flipped), + ("random", rng.vec(c.len())), + ] { + let ours = acvp::decaps_internal::(&dk, &input).unwrap(); + let theirs = + ref_dk.decapsulate(&Ciphertext::try_from(input.as_slice()).unwrap()); + assert_eq!( + ours.as_slice(), + theirs.as_slice(), + "round {round}: decaps of {label}" + ); + assert_eq!( + ours.as_slice() == k.as_slice(), + label == "valid", + "round {round}: {label}" + ); + } + + // Decaps with a changed dk_PKE or z (both pass the section 7.3 checks). + let mut changed = dk.to_vec(); + let position = [rng.below(t_bytes), z_start + rng.below(SEED_BYTES)][rng.below(2)]; + changed[position] ^= 1 << rng.below(BYTE_BITS); + #[allow(deprecated)] + let ref_changed = DecapsulationKey::from_expanded_bytes( + &Array::try_from(changed.as_slice()).unwrap(), + ); + let ref_changed = + ref_changed.expect("hash check passes when only dk_PKE or z changes"); + check_dk::(&changed).unwrap(); + let ours = acvp::decaps_internal::(&changed, &c).unwrap(); + let theirs = ref_changed.decapsulate(&ref_c); + assert_eq!( + ours.as_slice(), + theirs.as_slice(), + "round {round}: decaps, changed dk byte {position}" + ); + + // Key checks: both must give the same verdict on a random byte change. + let mut bad_ek = ek.clone(); + bad_ek[rng.below(ek.len())] = rng.bytes::<1>()[0]; + let ours = check_ek::(&bad_ek).is_ok(); + let theirs = EncapsulationKey::new_from_slice(&bad_ek).is_ok(); + assert_eq!(ours, theirs, "round {round}: ek check after a random byte"); + + let mut overflow_ek = ek.clone(); + let value = KYBER_Q + rng.below((1 << COEFFICIENT_BITS) - KYBER_Q); + set_coefficient( + &mut overflow_ek[..t_bytes], + rng.below(K * KYBER_N), + value as u16, + ); + assert!( + check_ek::(&overflow_ek).is_err(), + "round {round}: coefficient {value}" + ); + assert!( + EncapsulationKey::new_from_slice(&overflow_ek).is_err(), + "round {round}: reference, {value}" + ); + + let mut bad_dk = dk.to_vec(); + let position = t_bytes + rng.below(z_start - t_bytes); + bad_dk[position] = + bad_dk[position].wrapping_add(1 + rng.below(u8::MAX as usize) as u8); + let ours = check_dk::(&bad_dk).is_ok(); + #[allow(deprecated)] + let theirs = DecapsulationKey::from_expanded_bytes( + &Array::try_from(bad_dk.as_slice()).unwrap(), + ) + .is_ok(); + assert_eq!( + ours, theirs, + "round {round}: dk check after a change at byte {position}" + ); + assert!( + !ours, + "round {round}: a change inside ek or H(ek) must fail the hash check" + ); + } + } + }; +} + +differential_test!( + ml_kem_512_matches_the_reference, + MlKem512, + ml_kem_512, + MlKem512 +); +differential_test!( + ml_kem_768_matches_the_reference, + MlKem768, + ml_kem_768, + MlKem768 +); +differential_test!( + ml_kem_1024_matches_the_reference, + MlKem1024, + ml_kem_1024, + MlKem1024 +); diff --git a/src/mlkem/kem.rs b/src/mlkem/kem.rs new file mode 100644 index 0000000..603db62 --- /dev/null +++ b/src/mlkem/kem.rs @@ -0,0 +1,466 @@ +//! ML-KEM (FIPS 203 sections 6 and 7): KeyGen_internal, Encaps_internal and +//! Decaps_internal (Algorithms 16 to 18) and the wrappers KeyGen, Encaps and +//! Decaps (Algorithms 19 to 21), generic over the parameter set. +//! +//! Unlike round-3 Kyber, `m` is not hashed before use and the shared secret +//! does not depend on `H(c)`: `(K, r) = G(m || H(ek))` (Algorithm 17). +//! +//! The `*_internal` functions take caller-chosen randomness or unchecked keys. +//! FIPS 203 section 3.3 allows them for testing only, so they are private; the +//! vector tests reach them through `acvp`, which exists under +//! `cfg(any(test, feature = "acvp"))`. The public functions are `keygen`, +//! `keygen_from_seed` (the stored `(d, z)` seed of section 3.3), `encaps` and +//! `decaps`; the input checks of sections 7.2 and 7.3 and the OS random +//! generator sit in front of them. +//! +//! Every secret intermediate (`d`, `z`, `m`, `r`, `K`, `K'`, `K-bar`, the +//! hash outputs and the re-encrypted ciphertext) is `Zeroizing`, so it is +//! wiped on the error paths too. The implicit-rejection flag exists only inside +//! `ct::reject_on_mismatch`. + +use super::check::{check_ct, check_dk, check_ek, check_len, split_dk, DkParts, HASH_BYTES}; +use super::compress::MSG_BYTES; +use super::ct::reject_on_mismatch; +use super::kpke; +use super::params::ParamSet; +use crate::kyber::error::KyberError; +use crate::kyber::params::{KYBER_SSBYTES, KYBER_SYMBYTES}; +use crate::kyber::symmetric::{hash_sha3_256, hash_sha3_512, shake256_kdf}; +use rand::rngs::OsRng; +use rand_core::{CryptoRng, RngCore}; +use zeroize::Zeroizing; + +/// The seeds d and z and the randomness m are 32 bytes (FIPS 203 Table 1). +const SEED_BYTES: usize = KYBER_SYMBYTES; +/// Output of G: the shared secret K (or K') and the randomness r (or r'). +const G_OUTPUT_BYTES: usize = KYBER_SSBYTES + SEED_BYTES; +/// Input of G in Algorithms 17 and 18: `m || H(ek)` or `m' || h`. +const G_INPUT_BYTES: usize = MSG_BYTES + HASH_BYTES; + +type Seed = [u8; SEED_BYTES]; +/// A 32-byte shared secret, wiped on drop. +pub(crate) type SharedSecret = Zeroizing<[u8; KYBER_SSBYTES]>; +/// `(ek, dk)`; `dk` is wiped on drop. +pub(crate) type Keypair = (Vec, Zeroizing>); + +/// Algorithm 16, ML-KEM.KeyGen_internal. +fn keygen_internal(d: &Seed, z: &Seed) -> Keypair { + // Step 1: (ek_PKE, dk_PKE) <- K-PKE.KeyGen(d). Step 2: ek <- ek_PKE. + let (ek, dk_pke) = kpke::keygen::(d); + // Step 3: dk <- dk_PKE || ek || H(ek) || z. + let mut h = [0u8; HASH_BYTES]; + hash_sha3_256(&mut h, &ek, ek.len()); + // Exact capacity: growing the vector would leave an unwiped copy of dk_PKE. + let mut dk = Zeroizing::new(Vec::with_capacity(ParamSet::::DK_BYTES)); + dk.extend_from_slice(&dk_pke); + dk.extend_from_slice(&ek); + dk.extend_from_slice(&h); + dk.extend_from_slice(z); + (ek, dk) +} + +/// Algorithm 17, ML-KEM.Encaps_internal. `ek` must have passed `check_ek`; +/// the length is re-checked here so that a wrong size cannot panic. +fn encaps_internal( + ek: &[u8], + m: &[u8; MSG_BYTES], +) -> Result<(SharedSecret, Vec), KyberError> { + check_len("ek", ParamSet::::EK_BYTES, ek.len())?; + + // Step 1: (K, r) <- G(m || H(ek)). No H(m) first, unlike round-3 Kyber. + let mut g_input = Zeroizing::new([0u8; G_INPUT_BYTES]); + g_input[..MSG_BYTES].copy_from_slice(m); + hash_sha3_256(&mut g_input[MSG_BYTES..], ek, ek.len()); + let mut g_output = Zeroizing::new([0u8; G_OUTPUT_BYTES]); + hash_sha3_512(&mut g_output[..], &g_input[..], G_INPUT_BYTES); + let mut k = Zeroizing::new([0u8; KYBER_SSBYTES]); + k.copy_from_slice(&g_output[..KYBER_SSBYTES]); + let mut r = Zeroizing::new([0u8; SEED_BYTES]); + r.copy_from_slice(&g_output[KYBER_SSBYTES..]); + + // Step 2: c <- K-PKE.Encrypt(ek, m, r). Step 3: return (K, c). + let c = kpke::encrypt::(ek, m, &r)?; + Ok((k, c)) +} + +/// Algorithm 18, ML-KEM.Decaps_internal. Both lengths are re-checked here so +/// that a wrong size cannot panic; the hash check of section 7.3 is +/// `check_dk`'s job. The return value is `K'` for a valid ciphertext and +/// `J(z || c)` otherwise, picked without a branch. +fn decaps_internal(dk: &[u8], c: &[u8]) -> Result { + check_ct::(c)?; + // Steps 1-4: dk_PKE, ek_PKE, h and z. + let DkParts { dk_pke, ek, h, z } = split_dk::(dk)?; + + // Step 5: m' <- K-PKE.Decrypt(dk_PKE, c). + let m = kpke::decrypt::(dk_pke, c)?; + + // Step 6: (K', r') <- G(m' || h). + let mut g_input = Zeroizing::new([0u8; G_INPUT_BYTES]); + g_input[..MSG_BYTES].copy_from_slice(&m[..]); + g_input[MSG_BYTES..].copy_from_slice(h); + let mut g_output = Zeroizing::new([0u8; G_OUTPUT_BYTES]); + hash_sha3_512(&mut g_output[..], &g_input[..], G_INPUT_BYTES); + let mut k_prime = Zeroizing::new([0u8; KYBER_SSBYTES]); + k_prime.copy_from_slice(&g_output[..KYBER_SSBYTES]); + let mut r_prime = Zeroizing::new([0u8; SEED_BYTES]); + r_prime.copy_from_slice(&g_output[KYBER_SSBYTES..]); + + // Step 7: K-bar <- J(z || c), with J = SHAKE256 and 32 bytes of output. + let mut j_input = Zeroizing::new(Vec::with_capacity(z.len() + c.len())); + j_input.extend_from_slice(z); + j_input.extend_from_slice(c); + let mut k_bar = Zeroizing::new([0u8; KYBER_SSBYTES]); + shake256_kdf(&mut k_bar[..], &j_input, j_input.len()); + + // Step 8: c' <- K-PKE.Encrypt(ek_PKE, m', r'). It depends on m', so it is wiped too. + let c_prime = Zeroizing::new(kpke::encrypt::(ek, &m, &r_prime)?); + + // Steps 9-11: if c != c' then K' <- K-bar, without a branch. + reject_on_mismatch(&mut k_prime, &k_bar, c, &c_prime); + + // Step 12. + Ok(k_prime) +} + +/// A 32-byte seed (d, z or m) from `rng`; a failing generator is an error, never +/// a fallback (Algorithm 19 steps 3-5, Algorithm 20 steps 2-4). +fn random_seed(rng: &mut R) -> Result, KyberError> { + let mut seed = Zeroizing::new([0u8; SEED_BYTES]); + rng.try_fill_bytes(&mut seed[..]) + .map_err(|_| KyberError::RandomBytesGeneration)?; + Ok(seed) +} + +/// Algorithm 19 with the generator as a parameter, so tests can make it fail. +fn keygen_with(rng: &mut R) -> Result { + // Steps 1-2: d and z, 32 random bytes each. + let d = random_seed(rng)?; + let z = random_seed(rng)?; + // Step 6. + Ok(keygen_internal::(&d, &z)) +} + +/// Algorithm 20 with the generator as a parameter. The key is checked before +/// any randomness is drawn. +fn encaps_with( + ek: &[u8], + rng: &mut R, +) -> Result<(SharedSecret, Vec), KyberError> { + check_ek::(ek)?; + // Step 1: m, 32 random bytes. + let m = random_seed(rng)?; + // Step 5. + encaps_internal::(ek, &m) +} + +/// ML-KEM.KeyGen (Algorithm 19), with `d` and `z` from the OS generator. +pub(crate) fn keygen() -> Result { + keygen_with::(&mut OsRng) +} + +/// ML-KEM.KeyGen_internal from a stored seed `(d, z)` (section 3.3 allows +/// keeping the seed in place of the key). The seed is as sensitive as `dk`. +pub(crate) fn keygen_from_seed(d: &Seed, z: &Seed) -> Keypair { + keygen_internal::(d, z) +} + +/// ML-KEM.Encaps (Algorithm 20): checks `ek` (section 7.2), draws `m` from the +/// OS generator and returns `(K, c)`. +pub(crate) fn encaps(ek: &[u8]) -> Result<(SharedSecret, Vec), KyberError> { + encaps_with::(ek, &mut OsRng) +} + +/// ML-KEM.Decaps (Algorithm 21): checks `c` and `dk` (section 7.3), then runs +/// Algorithm 18. An invalid but well-formed ciphertext is not an error: it +/// yields the implicit-rejection secret. +pub(crate) fn decaps(dk: &[u8], c: &[u8]) -> Result { + check_ct::(c)?; + check_dk::(dk)?; + decaps_internal::(dk, c) +} + +/// Algorithms 16 to 18 for vector tests. Not for applications (section 3.3). +#[cfg(any(test, feature = "acvp"))] +pub(crate) mod acvp { + use super::*; + + pub(crate) fn keygen_internal(d: &Seed, z: &Seed) -> Keypair { + super::keygen_internal::(d, z) + } + + pub(crate) fn encaps_internal( + ek: &[u8], + m: &[u8; MSG_BYTES], + ) -> Result<(SharedSecret, Vec), KyberError> { + super::encaps_internal::(ek, m) + } + + pub(crate) fn decaps_internal( + dk: &[u8], + c: &[u8], + ) -> Result { + super::decaps_internal::(dk, c) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::kyber::fips202::{sha3_256, sha3_512, shake256}; + use crate::kyber::params::KYBER_Q; + use crate::mlkem::params::{MlKem1024, MlKem512, MlKem768}; + use core::num::NonZeroU32; + + /// Round trips per parameter set. + const ROUNDS: usize = 4; + /// Bits in one coefficient of ByteEncode12 (FIPS 203 Algorithm 5, d = 12). + const COEFF_BITS: u32 = 12; + /// The bits of such a coefficient that spill into the second byte. + const SECOND_BYTE_BITS: u8 = (1 << (COEFF_BITS - u8::BITS)) - 1; + /// Ciphertext positions flipped in the rejection tests, spread over c1 and c2. + const FLIP_POSITIONS: usize = 7; + + /// A generator that yields zeros and fails after `ok_calls` successful fills. + struct FailingRng { + ok_calls: usize, + calls: usize, + } + impl FailingRng { + fn new(ok_calls: usize) -> Self { + FailingRng { ok_calls, calls: 0 } + } + } + impl RngCore for FailingRng { + fn next_u32(&mut self) -> u32 { + 0 + } + fn next_u64(&mut self) -> u64 { + 0 + } + fn fill_bytes(&mut self, dest: &mut [u8]) { + dest.fill(0); + } + fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), rand_core::Error> { + self.calls += 1; + if self.calls > self.ok_calls { + return Err(NonZeroU32::new(rand_core::Error::CUSTOM_START) + .unwrap() + .into()); + } + dest.fill(0xa5); + Ok(()) + } + } + impl CryptoRng for FailingRng {} + + fn h(input: &[u8]) -> [u8; HASH_BYTES] { + let mut out = [0u8; HASH_BYTES]; + sha3_256(&mut out, input, input.len()); + out + } + + fn g(input: &[u8]) -> [u8; G_OUTPUT_BYTES] { + let mut out = [0u8; G_OUTPUT_BYTES]; + sha3_512(&mut out, input, input.len()); + out + } + + fn j(input: &[u8]) -> [u8; KYBER_SSBYTES] { + let mut out = [0u8; KYBER_SSBYTES]; + shake256(&mut out, KYBER_SSBYTES, input, input.len()); + out + } + + /// Runs the generic test `$name::` for each of the three parameter sets. + macro_rules! all_sets { + ($name:ident, $test:ident) => { + #[test] + fn $test() { + $name::<{ MlKem512::K }>(); + $name::<{ MlKem768::K }>(); + $name::<{ MlKem1024::K }>(); + } + }; + } + + fn round_trip() { + for round in 0..ROUNDS { + let (ek, dk) = keygen::().unwrap(); + assert_eq!(ek.len(), ParamSet::::EK_BYTES); + assert_eq!(dk.len(), ParamSet::::DK_BYTES); + let (k_enc, c) = encaps::(&ek).unwrap(); + assert_eq!(c.len(), ParamSet::::CT_BYTES); + let k_dec = decaps::(&dk, &c).unwrap(); + assert_eq!(*k_enc, *k_dec, "K = {K}, round {round}"); + // Fresh randomness each time. + let (k_again, c_again) = encaps::(&ek).unwrap(); + assert_ne!(c, c_again); + assert_ne!(*k_enc, *k_again); + } + } + all_sets!(round_trip, round_trip_for_all_sets); + + /// Algorithm 16 step 3: dk = dk_PKE || ek || H(ek) || z, deterministic in (d, z). + fn keygen_layout() { + let (d, z) = ([1u8; SEED_BYTES], [2u8; SEED_BYTES]); + let (ek, dk) = keygen_from_seed::(&d, &z); + let (pke_ek, dk_pke) = kpke::keygen::(&d); + assert_eq!(ek, pke_ek); + let parts = split_dk::(&dk).unwrap(); + assert_eq!(parts.dk_pke, &dk_pke[..]); + assert_eq!(parts.ek, &ek[..]); + assert_eq!(parts.h, h(&ek)); + assert_eq!(parts.z, z); + let (ek2, dk2) = keygen_from_seed::(&d, &z); + assert_eq!((&ek, &*dk), (&ek2, &*dk2)); + // z only changes the tail of dk; d changes ek. + let (ek3, dk3) = keygen_from_seed::(&d, &[3u8; SEED_BYTES]); + assert_eq!(ek, ek3); + assert_eq!(dk[..dk.len() - SEED_BYTES], dk3[..dk3.len() - SEED_BYTES]); + assert_ne!(dk, dk3); + assert_ne!(ek, keygen_from_seed::(&[9u8; SEED_BYTES], &z).0); + } + all_sets!(keygen_layout, keygen_layout_for_all_sets); + + /// Algorithm 17: (K, r) = G(m || H(ek)), no H(m), no H(c). + fn encaps_derivation() { + let (ek, _) = keygen_from_seed::(&[4u8; SEED_BYTES], &[5u8; SEED_BYTES]); + let m = [6u8; MSG_BYTES]; + let (k, c) = acvp::encaps_internal::(&ek, &m).unwrap(); + let kr = g(&[&m[..], &h(&ek)].concat()); + assert_eq!(*k, kr[..KYBER_SSBYTES]); + let r: Seed = kr[KYBER_SSBYTES..].try_into().unwrap(); + assert_eq!(c, kpke::encrypt::(&ek, &m, &r).unwrap()); + // Round-3 Kyber hashes m first and puts H(c) in the KDF; neither may happen here. + let round3 = g(&[&h(&m)[..], &h(&ek)].concat()); + assert_ne!(*k, round3[..KYBER_SSBYTES]); + let (k2, c2) = acvp::encaps_internal::(&ek, &m).unwrap(); + assert_eq!((*k, c), (*k2, c2)); + } + all_sets!(encaps_derivation, encaps_derivation_for_all_sets); + + /// A modified ciphertext gives J(z || c), different from K and from the + /// secret of any other modified ciphertext, and the same on every call. + fn implicit_rejection() { + let (d, z) = ([7u8; SEED_BYTES], [8u8; SEED_BYTES]); + let (ek, dk) = keygen_from_seed::(&d, &z); + let (k, c) = acvp::encaps_internal::(&ek, &[9u8; MSG_BYTES]).unwrap(); + assert_eq!(*decaps::(&dk, &c).unwrap(), *k); + + let mut seen = vec![*k]; + let step = c.len() / FLIP_POSITIONS; + for pos in (0..c.len()).step_by(step) { + let mut bad = c.clone(); + bad[pos] ^= 1; + let rejected = decaps::(&dk, &bad).unwrap(); + assert_eq!(*rejected, j(&[&z[..], &bad].concat()), "pos {pos}"); + assert_eq!(*rejected, *decaps::(&dk, &bad).unwrap(), "deterministic"); + assert!(!seen.contains(&*rejected), "pos {pos}"); + seen.push(*rejected); + } + // The last byte of c2 and an all-zero ciphertext are rejected too. + let mut tail = c.clone(); + *tail.last_mut().unwrap() ^= u8::MAX; + assert_eq!( + *decaps::(&dk, &tail).unwrap(), + j(&[&z[..], &tail].concat()) + ); + let zeros = vec![0u8; c.len()]; + assert_eq!( + *decaps::(&dk, &zeros).unwrap(), + j(&[&z[..], &zeros].concat()) + ); + // A key that did not make the ciphertext rejects it as well. + let (_, other_dk) = keygen_from_seed::(&[10u8; SEED_BYTES], &[11u8; SEED_BYTES]); + let other = decaps::(&other_dk, &c).unwrap(); + assert_ne!(*other, *k); + assert_eq!(*other, j(&[&[11u8; SEED_BYTES][..], &c].concat())); + } + all_sets!(implicit_rejection, implicit_rejection_for_all_sets); + + fn encaps_rejects_bad_keys_before_drawing_randomness() { + let (ek, _) = keygen_from_seed::(&[12u8; SEED_BYTES], &[13u8; SEED_BYTES]); + let mut rng = FailingRng::new(usize::MAX); + + let len = ParamSet::::EK_BYTES; + for bad in [&ek[1..], &ek[..0], &[&ek[..], &[0]].concat()[..]] { + assert_eq!( + encaps_with::(bad, &mut rng), + Err(KyberError::invalid_input("ek", len, bad.len())) + ); + } + // First coefficient set to q: 12 bits, little endian across bytes 0 and 1. + let mut unreduced = ek.clone(); + let [low, high] = (KYBER_Q as u16).to_le_bytes(); + unreduced[0] = low; + unreduced[1] = (unreduced[1] & !SECOND_BYTE_BITS) | high; + assert_eq!( + encaps_with::(&unreduced, &mut rng), + Err(KyberError::InvalidEncapsulationKey) + ); + assert_eq!(rng.calls, 0, "the generator was not touched"); + // The same ek passes once the key is fine. + assert!(encaps_with::(&ek, &mut rng).is_ok()); + assert_eq!(rng.calls, 1); + } + all_sets!( + encaps_rejects_bad_keys_before_drawing_randomness, + encaps_rejects_bad_keys_for_all_sets + ); + + fn decaps_rejects_bad_inputs() { + let (ek, dk) = keygen_from_seed::(&[14u8; SEED_BYTES], &[15u8; SEED_BYTES]); + let (_, c) = acvp::encaps_internal::(&ek, &[16u8; MSG_BYTES]).unwrap(); + let bad_c = |n| KyberError::invalid_input("c", ParamSet::::CT_BYTES, n); + let bad_dk = |n| KyberError::invalid_input("dk", ParamSet::::DK_BYTES, n); + // The ciphertext length is checked on every call, valid key or not. + assert_eq!( + decaps::(&dk, &c[1..]).map(|k| *k), + Err(bad_c(c.len() - 1)) + ); + assert_eq!(decaps::(&dk, &[]).map(|k| *k), Err(bad_c(0))); + assert_eq!( + decaps::(&dk, &[&c[..], &[0]].concat()).map(|k| *k), + Err(bad_c(c.len() + 1)) + ); + assert_eq!( + decaps::(&dk[1..], &c).map(|k| *k), + Err(bad_dk(dk.len() - 1)) + ); + assert_eq!(decaps::(&[], &c).map(|k| *k), Err(bad_dk(0))); + // Hash check: the stored H(ek) and the embedded ek must agree. + let h_at = ParamSet::::DK_PKE_BYTES + ParamSet::::EK_BYTES; + for flip in [h_at, ParamSet::::DK_PKE_BYTES] { + let mut bad = dk.to_vec(); + bad[flip] ^= 1; + assert_eq!( + decaps::(&bad, &c).map(|k| *k), + Err(KyberError::InvalidDecapsulationKey) + ); + } + // The internal function does not check the hash, but never panics on lengths. + assert!(acvp::decaps_internal::(&dk[1..], &c).is_err()); + assert!(acvp::decaps_internal::(&dk, &c[1..]).is_err()); + assert!(acvp::encaps_internal::(&ek[1..], &[0u8; MSG_BYTES]).is_err()); + } + all_sets!( + decaps_rejects_bad_inputs, + decaps_rejects_bad_inputs_for_all_sets + ); + + #[test] + fn a_failing_generator_is_an_error_not_a_fallback() { + type P = MlKem768; + let err = Err(KyberError::RandomBytesGeneration); + // d fails, z fails, both fine. + assert_eq!(keygen_with::<{ P::K }, _>(&mut FailingRng::new(0)), err); + assert_eq!(keygen_with::<{ P::K }, _>(&mut FailingRng::new(1)), err); + assert!(keygen_with::<{ P::K }, _>(&mut FailingRng::new(2)).is_ok()); + let (ek, _) = keygen_from_seed::<{ P::K }>(&[0u8; SEED_BYTES], &[0u8; SEED_BYTES]); + assert_eq!( + encaps_with::<{ P::K }, _>(&ek, &mut FailingRng::new(0)).map(|(k, _)| *k), + Err(KyberError::RandomBytesGeneration) + ); + } +} diff --git a/src/mlkem/kpke.rs b/src/mlkem/kpke.rs new file mode 100644 index 0000000..7a819ff --- /dev/null +++ b/src/mlkem/kpke.rs @@ -0,0 +1,346 @@ +//! K-PKE, the public-key encryption scheme inside ML-KEM (FIPS 203 section 5): +//! KeyGen (Algorithm 13), Encrypt (Algorithm 14) and Decrypt (Algorithm 15), +//! generic over the parameter set. +//! +//! Step numbers in comments refer to the algorithm named in the same comment. +//! The matrix is public (it is derived from rho), so it is re-sampled one row or +//! column at a time instead of being stored; only `[Poly; K]` arrays are used +//! because stable Rust cannot size arrays from `ParamSet` constants. +//! +//! Everything derived from `d`, `m`, `r`, `sigma`, `s` or `y` is secret. Loops +//! and indices depend only on `K` and the public sampling of the matrix, secret +//! intermediates are `Zeroizing` or `Poly`/`PolyVec` (zeroized on drop), and the +//! message goes through `compress::poly_from_msg` and `poly_compress` with +//! `d = 1`. + +use super::compress::{compressed_bytes, poly_compress, poly_decompress, poly_from_msg, MSG_BYTES}; +use super::params::{prf_bytes, ParamSet}; +use super::polyvec::PolyVec; +use super::sample::{sample_ntt, INPUT_BYTES}; +use crate::kyber::cbd::{cbd2, cbd3}; +use crate::kyber::error::KyberError; +use crate::kyber::params::KYBER_SYMBYTES; +use crate::kyber::poly::{poly_add, poly_invntt_tomont, poly_reduce, poly_sub, poly_tomont, Poly}; +use crate::kyber::symmetric::{hash_sha3_512, shake256_prf_expand}; +use zeroize::Zeroizing; + +/// Seeds d, rho, sigma and the randomness r are 32 bytes (FIPS 203 Table 1). +const SEED_BYTES: usize = KYBER_SYMBYTES; +/// Input of G in Algorithm 13: `d || k`. +const KEYGEN_INPUT_BYTES: usize = SEED_BYTES + 1; +/// Output of G: two 32-byte seeds. +const G_OUTPUT_BYTES: usize = 2 * SEED_BYTES; +/// The two values of eta in Table 2 and the CBD routine each one selects. +const ETA_2: usize = 2; +const ETA_3: usize = 3; +/// Largest PRF output for one noise polynomial (eta = 3). +const MAX_NOISE_BYTES: usize = prf_bytes(ETA_3); +/// Compression width of the message in Decrypt (Algorithm 15, step 7). +const D_MESSAGE: u32 = 1; + +type Seed = [u8; SEED_BYTES]; + +/// One noise polynomial: `SamplePolyCBD_eta(PRF_eta(seed, nonce))`, then the +/// nonce counter `N` advances (Algorithm 13 steps 9-10, 13-14; Algorithm 14 +/// steps 10-11, 14-15, 17). `eta` is a public parameter. +fn sample_noise(out: &mut Poly, seed: &Seed, nonce: &mut u8, eta: usize) { + let mut buf = Zeroizing::new([0u8; MAX_NOISE_BYTES]); + let len = prf_bytes(eta); + shake256_prf_expand(&mut buf[..len], len, seed, *nonce); + match eta { + ETA_2 => cbd2(out, &buf[..len]), + ETA_3 => cbd3(out, &buf[..len]), + _ => unreachable!("ML-KEM defines eta in {{2, 3}}"), + } + *nonce += 1; +} + +/// `K` noise polynomials in a row, as in the loops of Algorithms 13 and 14. +fn sample_noise_vec(out: &mut PolyVec, seed: &Seed, nonce: &mut u8, eta: usize) { + for p in out.vec.iter_mut() { + sample_noise(p, seed, nonce, eta); + } +} + +/// Row `i` of the matrix, or with `transposed` its column `i` (row `i` of the +/// transpose), in the NTT domain. Entry `A[i][j]` is `SampleNTT(rho || j || i)` +/// (Algorithm 13 step 5, Algorithm 14 step 6). Public data. +fn matrix_row(rho: &Seed, i: usize, transposed: bool) -> PolyVec { + let mut row = PolyVec::::new(); + for (j, p) in row.vec.iter_mut().enumerate() { + // `A[row][col] = SampleNTT(rho || col || row)`; K <= 4 fits a byte. + let (row_index, col_index) = if transposed { (j, i) } else { (i, j) }; + let mut input = [0u8; INPUT_BYTES]; + input[..SEED_BYTES].copy_from_slice(rho); + input[SEED_BYTES] = col_index as u8; + input[SEED_BYTES + 1] = row_index as u8; + *p = sample_ntt(&input); + } + row +} + +fn check_len(param: &'static str, expected: usize, actual: usize) -> Result<(), KyberError> { + if expected == actual { + Ok(()) + } else { + Err(KyberError::invalid_input(param, expected, actual)) + } +} + +/// K-PKE.KeyGen (Algorithm 13): returns `(ek_PKE, dk_PKE)`. +pub(crate) fn keygen(d: &Seed) -> (Vec, Zeroizing>) { + // Step 1: (rho, sigma) <- G(d || k). The byte k is the domain separator. + let mut g_input = Zeroizing::new([0u8; KEYGEN_INPUT_BYTES]); + g_input[..SEED_BYTES].copy_from_slice(d); + g_input[SEED_BYTES] = ParamSet::::K as u8; + let mut g_output = Zeroizing::new([0u8; G_OUTPUT_BYTES]); + hash_sha3_512(&mut g_output[..], &g_input[..], KEYGEN_INPUT_BYTES); + let mut rho = [0u8; SEED_BYTES]; + rho.copy_from_slice(&g_output[..SEED_BYTES]); + let mut sigma = Zeroizing::new([0u8; SEED_BYTES]); + sigma.copy_from_slice(&g_output[SEED_BYTES..]); + keygen_expanded::(&rho, &sigma) +} + +/// Algorithm 13 from step 2 on, for a given `(rho, sigma)`. Split from `keygen` +/// so the CCTV tests can feed the `G(d)` derivation of the pre-final standard. +pub(crate) fn keygen_expanded( + rho: &Seed, + sigma: &Seed, +) -> (Vec, Zeroizing>) { + // Steps 2, 8-15: s and e from sigma, nonces 0..K and K..2K. + let mut nonce = 0u8; + let mut s = PolyVec::::new(); + let mut e = PolyVec::::new(); + sample_noise_vec(&mut s, sigma, &mut nonce, ParamSet::::ETA1); + sample_noise_vec(&mut e, sigma, &mut nonce, ParamSet::::ETA1); + + // Steps 16-17. + s.ntt(); + e.ntt(); + + // Step 18: t = A s + e. The product carries 2^-16; poly_tomont removes it. + let mut t = PolyVec::::new(); + for (i, ti) in t.vec.iter_mut().enumerate() { + PolyVec::basemul_acc(ti, &matrix_row::(rho, i, false), &s); + poly_tomont(ti); + } + t.add(&e); + t.reduce(); + + // Steps 19-20. + let mut ek = vec![0u8; ParamSet::::EK_BYTES]; + let (ek_t, ek_rho) = ek.split_at_mut(ParamSet::::DK_PKE_BYTES); + t.to_bytes(ek_t); + ek_rho.copy_from_slice(rho); + let mut dk = Zeroizing::new(vec![0u8; ParamSet::::DK_PKE_BYTES]); + s.to_bytes(&mut dk); + (ek, dk) +} + +/// K-PKE.Encrypt (Algorithm 14). Fails if `ek` is not `EK_BYTES` long; the +/// modulus check of FIPS 203 section 7.2 belongs to the caller. +pub(crate) fn encrypt( + ek: &[u8], + m: &[u8; MSG_BYTES], + r: &Seed, +) -> Result, KyberError> { + check_len("ek", ParamSet::::EK_BYTES, ek.len())?; + let (ek_t, ek_rho) = ek.split_at(ParamSet::::DK_PKE_BYTES); + + // Steps 2-3. + let t = PolyVec::::from_bytes(ek_t); + let mut rho = [0u8; SEED_BYTES]; + rho.copy_from_slice(ek_rho); + + // Steps 1, 9-17: y, e1 (eta2) and e2 (eta2) from r. + let mut nonce = 0u8; + let mut y = PolyVec::::new(); + let mut e1 = PolyVec::::new(); + let mut e2 = Poly::new(); + sample_noise_vec(&mut y, r, &mut nonce, ParamSet::::ETA1); + sample_noise_vec(&mut e1, r, &mut nonce, ParamSet::::ETA2); + sample_noise(&mut e2, r, &mut nonce, ParamSet::::ETA2); + + // Step 18. + y.ntt(); + + // Step 19: u = NTT^-1(A^T y) + e1. basemul's 2^-16 and invntt's 2^16 cancel. + let mut u = PolyVec::::new(); + for (i, ui) in u.vec.iter_mut().enumerate() { + PolyVec::basemul_acc(ui, &matrix_row::(&rho, i, true), &y); + } + u.invntt_tomont(); + u.add(&e1); + u.reduce(); + + // Steps 20-21: v = NTT^-1(t^T y) + e2 + mu. + let mut mu = Poly::new(); + poly_from_msg(&mut mu, m); + let mut v = Poly::new(); + PolyVec::basemul_acc(&mut v, &t, &y); + poly_invntt_tomont(&mut v); + poly_add(&mut v, &e2); + poly_add(&mut v, &mu); + poly_reduce(&mut v); + + // Steps 22-24: c = c1 || c2. + let u_bytes = ParamSet::::POLY_COMPRESSED_U_BYTES; + let mut c = vec![0u8; ParamSet::::CT_BYTES]; + let (c1, c2) = c.split_at_mut(K * u_bytes); + for (ci, ui) in c1.chunks_exact_mut(u_bytes).zip(u.vec.iter()) { + poly_compress(ci, ui, ParamSet::::DU as u32); + } + poly_compress(c2, &v, ParamSet::::DV as u32); + Ok(c) +} + +/// K-PKE.Decrypt (Algorithm 15). Fails if `dk` is not `DK_PKE_BYTES` long or +/// `c` is not `CT_BYTES` long; both lengths are checked before any work. +pub(crate) fn decrypt( + dk: &[u8], + c: &[u8], +) -> Result, KyberError> { + check_len("dk", ParamSet::::DK_PKE_BYTES, dk.len())?; + check_len("c", ParamSet::::CT_BYTES, c.len())?; + + // Steps 1-4. + let u_bytes = ParamSet::::POLY_COMPRESSED_U_BYTES; + let (c1, c2) = c.split_at(K * u_bytes); + let mut u = PolyVec::::new(); + for (ui, ci) in u.vec.iter_mut().zip(c1.chunks_exact(u_bytes)) { + poly_decompress(ui, ci, ParamSet::::DU as u32); + } + let mut v = Poly::new(); + poly_decompress(&mut v, c2, ParamSet::::DV as u32); + + // Step 5. + let s = PolyVec::::from_bytes(dk); + + // Step 6: w = v' - NTT^-1(s^T NTT(u')). poly_sub(r, a) sets r = a - r. + u.ntt(); + let mut w = Poly::new(); + PolyVec::basemul_acc(&mut w, &s, &u); + poly_invntt_tomont(&mut w); + poly_sub(&mut w, &v); + poly_reduce(&mut w); + + // Step 7: m = ByteEncode_1(Compress_1(w)). + let mut m = Zeroizing::new([0u8; MSG_BYTES]); + debug_assert_eq!(m.len(), compressed_bytes(D_MESSAGE)); + poly_compress(&mut m[..], &w, D_MESSAGE); + Ok(m) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::kyber::fips202::sha3_512; + use crate::mlkem::params::{MlKem1024, MlKem512, MlKem768}; + + /// Distinct inputs per set in the round-trip test. + const ROUNDS: usize = 8; + + /// xorshift64*, enough to vary test inputs; not a source of secrets. + struct Prng(u64); + impl Prng { + fn seed(&mut self) -> Seed { + let mut out = [0u8; SEED_BYTES]; + for byte in out.iter_mut() { + self.0 ^= self.0 >> 12; + self.0 ^= self.0 << 25; + self.0 ^= self.0 >> 27; + *byte = (self.0.wrapping_mul(0x2545_f491_4f6c_dd1d) >> 56) as u8; + } + out + } + } + + fn g(input: &[u8]) -> [u8; G_OUTPUT_BYTES] { + let mut out = [0u8; G_OUTPUT_BYTES]; + sha3_512(&mut out, input, input.len()); + out + } + + fn keygen_uses_g_of_d_and_k() { + let d = Prng(1).seed(); + let (ek, dk) = keygen::(&d); + assert_eq!(ek.len(), ParamSet::::EK_BYTES); + assert_eq!(dk.len(), ParamSet::::DK_PKE_BYTES); + // rho is the first half of G(d || k), the trailing seed of ek. + let mut input = d.to_vec(); + input.push(K as u8); + assert_eq!(ek[ek.len() - SEED_BYTES..], g(&input)[..SEED_BYTES]); + // Round-3 Kyber used G(d); it must not be what ML-KEM produces. + assert_ne!(ek[ek.len() - SEED_BYTES..], g(&d)[..SEED_BYTES]); + // Deterministic in d. + let (ek2, dk2) = keygen::(&d); + assert_eq!((ek.clone(), dk.to_vec()), (ek2, dk2.to_vec())); + } + + #[test] + fn keygen_uses_g_of_d_and_k_for_all_sets() { + keygen_uses_g_of_d_and_k::<{ MlKem512::K }>(); + keygen_uses_g_of_d_and_k::<{ MlKem768::K }>(); + keygen_uses_g_of_d_and_k::<{ MlKem1024::K }>(); + } + + fn round_trip() { + let mut rng = Prng(K as u64); + for round in 0..ROUNDS { + let (ek, dk) = keygen::(&rng.seed()); + // Include the all-zeros and all-ones messages, then random ones. + let m = match round { + 0 => [0u8; MSG_BYTES], + 1 => [0xff; MSG_BYTES], + _ => rng.seed(), + }; + let r = rng.seed(); + let c = encrypt::(&ek, &m, &r).unwrap(); + assert_eq!(c.len(), ParamSet::::CT_BYTES); + assert_eq!(c, encrypt::(&ek, &m, &r).unwrap(), "deterministic in r"); + assert_ne!(c, encrypt::(&ek, &m, &rng.seed()).unwrap()); + assert_eq!(*decrypt::(&dk, &c).unwrap(), m, "K = {K}, round {round}"); + } + } + + #[test] + fn decrypt_inverts_encrypt_for_all_sets() { + round_trip::<{ MlKem512::K }>(); + round_trip::<{ MlKem768::K }>(); + round_trip::<{ MlKem1024::K }>(); + } + + #[test] + fn a_different_key_does_not_decrypt() { + let mut rng = Prng(7); + let (ek, _) = keygen::<{ MlKem768::K }>(&rng.seed()); + let (_, other_dk) = keygen::<{ MlKem768::K }>(&rng.seed()); + let m = rng.seed(); + let c = encrypt::<{ MlKem768::K }>(&ek, &m, &rng.seed()).unwrap(); + assert_ne!(*decrypt::<{ MlKem768::K }>(&other_dk, &c).unwrap(), m); + } + + #[test] + fn lengths_are_checked_before_any_work() { + type P = MlKem768; + let mut rng = Prng(9); + let (ek, dk) = keygen::<{ P::K }>(&rng.seed()); + let m = rng.seed(); + let c = encrypt::<{ P::K }>(&ek, &m, &rng.seed()).unwrap(); + let bad = |param, expected, actual| KyberError::invalid_input(param, expected, actual); + assert_eq!( + encrypt::<{ P::K }>(&ek[1..], &m, &m), + Err(bad("ek", P::EK_BYTES, P::EK_BYTES - 1)) + ); + assert_eq!( + decrypt::<{ P::K }>(&dk[1..], &c).map(|m| *m), + Err(bad("dk", P::DK_PKE_BYTES, P::DK_PKE_BYTES - 1)) + ); + assert_eq!( + decrypt::<{ P::K }>(&dk, &c[1..]).map(|m| *m), + Err(bad("c", P::CT_BYTES, P::CT_BYTES - 1)) + ); + assert!(decrypt::<{ P::K }>(&[], &[]).is_err()); + } +} diff --git a/src/mlkem/mod.rs b/src/mlkem/mod.rs new file mode 100644 index 0000000..0d04d20 --- /dev/null +++ b/src/mlkem/mod.rs @@ -0,0 +1,106 @@ +//! ML-KEM (FIPS 203) core, work in progress. +//! +//! Round-3 Kyber and ML-KEM differ in `G(d || k)`, the missing `H(m)` and the +//! missing `H(c)` in the KDF, so the KEM layer is separate code. The +//! primitives below it are shared with `crate::kyber` where verified. New code +//! is generic over `k` (`params::ParamSet`, `polyvec::PolyVec`); do not add +//! another `_512/_768/_1024` copy. +//! +//! # What is shared with the legacy code, and what is not +//! +//! Audit for WP-2 against FIPS 203 (final, published 2024-08-13). Every +//! "shared" row is checked by `tests/mlkem_shared_primitives.rs` (or the unit +//! tests next to the new code) against a literal transcription of the +//! algorithm, or against hashlib vectors for SHA-3/SHAKE. +//! +//! Shared as is: +//! - `fips202` / `symmetric`: H = `sha3_256`, G = `sha3_512`, J and PRF = +//! `shake256`, XOF = SHAKE128 absorb / squeeze-blocks. Lengths around the +//! rates and multi-block outputs are covered. `sha3_256` / `sha3_512` now +//! zeroize their state. `xof_absorb_bytes(state, rho, a, b)` absorbs +//! `rho || a || b`, so Algorithm 13's `rho || j || i` is `(j, i)`. +//! - `reduce`: Montgomery and Barrett reduction, pure arithmetic mod q, the +//! only division is by the public constant q. +//! - `ntt`: `ZETAS` are `17^BitRev7(i) * 2^16 mod q`. `ntt_forward` is +//! Algorithm 9. `ntt_inverse` is Algorithm 10 times 2^16 ("tomont"), and +//! `poly_base_multiply` is Algorithms 11/12 times 2^-16, so compose them as +//! K-PKE does in the legacy code and the factors cancel. +//! - `cbd`: `cbd2` and `cbd3` are Algorithm 8 (signed representatives instead +//! of `mod q`, equal mod q). +//! - `poly_tobytes`: ByteEncode12 for coefficients in (-q, q). +//! - Compress / Decompress for d = 1 (`poly_tomsg`), 4, 5 and 10 are exact +//! over all of Z_q, and Decompress for d = 11 is exact. `poly_tomsg` is +//! arithmetically right; see "Checked in release builds" below. +//! K-PKE does not call them: `compress::poly_compress` and `poly_decompress` +//! are generic over d and cover every width, since the d = 10 / 11 legacy loops +//! exist only inside the per-level polyvec functions. +//! - `rej_uniform` (now `pub(crate)`), used by `sample::sample_ntt`. +//! +//! Not shared: +//! - `poly_frombytes`: no reduction mod q, unlike ByteDecode12. A modulus +//! check built on it would always pass. Use `PolyVec::from_bytes`. +//! - Compress_11 (`polyvec_compress_1024`): the multiplier 10321345 >> 35 is +//! not exact for d = 11 and is off by one at the residues 1875 and 3125. +//! Kyber-1024 output must not change (applying the fix makes the golden +//! vector test fail), so ML-KEM-1024 uses `compress::poly_compress` with d = 11, +//! with `((x << 11) + 1664) * 645084 >> 31`. That multiplier is checked +//! against FIPS 203 (4.7) for every residue; the pq-crystals reference +//! implementation also uses it. Decompress_11 in the legacy code is exact. +//! - `poly_frommsg`: builds the message mask with `wrapping_neg()` of a bit, +//! the pattern of CVE-2024-37880. rustc 1.95 did not turn it into a branch +//! on x86_64 or aarch64 (checked in release builds), but nothing guarantees +//! that for other compilers or flags, so ML-KEM needs its own version that +//! passes the bit through an optimization barrier: `compress::poly_from_msg`. +//! - `verify` / `cmov` (`verify.rs`): a caller-supplied length (the legacy code +//! has had an optimization barrier here since the WP-9 pass). ML-KEM keeps +//! its own compare and select. +//! - `poly_getnoise_*`: eta is hard-coded per function. Call the PRF and +//! `cbd2` / `cbd3` with `ParamSet::ETA1` / `ETA2` instead. +//! - `gen_matrix*`, `indcpa_*`, `crypto_kem_*`, the `Polyvec{512,768,1024}` +//! types and `api.rs`: per-level copies and round-3 logic. +//! +//! # Checked in release builds +//! +//! rustc 1.95, `--release`, inspected with objdump (x86_64) and `--emit asm` +//! (aarch64-linux-android): `poly_tomsg`, `poly_compress`, `poly_compress_1024`, +//! `polyvec_compress`, `polyvec_compress_1024`, `poly_tobytes` and +//! `compress::poly_compress` with d = 11 (x86_64 only) contain no division, and every +//! conditional branch compares a length, a loop counter or a bounds check, never +//! a coefficient. `sha3_256` and `sha3_512` keep the stores that zero their +//! state. This is not the WP-8 gate: armv7 and i686 are not covered and the +//! result holds for this toolchain only. +//! +//! K-PKE (WP-3), same toolchain, x86_64, objdump of the release test binary: +//! `kpke::keygen`, `encrypt`, `decrypt`, `sample_noise` and `matrix_row` contain +//! no division. Probes around `poly_from_msg`, `poly_compress` (d = 1, 10) and +//! `poly_decompress` (d = 10) show conditional branches only on loop counters, +//! output bounds and slice ends. `poly_from_msg` turns each message bit into a +//! mask with `bt` / `sbb`, stores it to the stack through `black_box` and applies +//! it with `and`. The `setcc` / `cmov` pairs in `decrypt` compare slice offsets +//! (public). Neither armv7 nor i686 nor aarch64 was looked at for K-PKE. +//! +//! Known limit: the Keccak permutation spills round temporaries into its own +//! stack frame (about 70 spill stores in a 0xf8-byte frame on x86_64), and +//! zeroizing the state array cannot clear those. Closing that needs stack +//! scrubbing, which is out of scope here; WP-9 should record it. +//! +//! Range discipline: `poly_add` and `poly_sub` are plain `i16` operations (they +//! panic on overflow in debug builds, wrap in release), so reduce between +//! steps as the legacy code does. + +#[cfg(test)] +mod acvp_tests; +#[cfg(test)] +mod cctv_tests; +pub mod check; +pub mod compress; +pub mod ct; +#[cfg(test)] +mod differential_tests; +pub mod kem; +pub mod kpke; +pub mod params; +pub mod polyvec; +pub mod sample; +#[cfg(test)] +mod wycheproof_tests; diff --git a/src/mlkem/params.rs b/src/mlkem/params.rs new file mode 100644 index 0000000..19bfa60 --- /dev/null +++ b/src/mlkem/params.rs @@ -0,0 +1,195 @@ +//! ML-KEM parameter sets as one type generic over `k` (FIPS 203 Table 2). +//! +//! `k` fixes every other parameter, so there is a single `ParamSet` and the +//! three standard sets are aliases. Stable Rust cannot use these associated +//! constants in array lengths of generic code, so byte buffers in generic +//! functions are slices or `Vec`s sized from them; only `[Poly; K]` is a +//! plain array. + +#[cfg(test)] +use crate::kyber::params::KYBER_SSBYTES; +use crate::kyber::params::{KYBER_N, KYBER_POLYBYTES, KYBER_SYMBYTES}; + +/// Parameter set selected by `K`. Using `ParamSet` for a `K` outside +/// {2, 3, 4} fails at compile time. +pub struct ParamSet; + +/// ML-KEM-512 (k = 2). +pub type MlKem512 = ParamSet<2>; +/// ML-KEM-768 (k = 3). +pub type MlKem768 = ParamSet<3>; +/// ML-KEM-1024 (k = 4). +pub type MlKem1024 = ParamSet<4>; + +/// Bytes of ByteEncode_d for one polynomial: 256 coefficients of `d` bits +/// (FIPS 203 Algorithm 5). +const fn encoded_bytes(d: usize) -> usize { + KYBER_N * d / u8::BITS as usize +} + +/// Bytes PRF_eta must produce for one noise polynomial: 2 * eta bits per +/// coefficient (FIPS 203 Algorithm 8, input of 64 * eta bytes). +pub(crate) const fn prf_bytes(eta: usize) -> usize { + KYBER_N * 2 * eta / u8::BITS as usize +} + +// The parameter values below are FIPS 203 Table 2, one arm per set. +const fn eta1(k: usize) -> usize { + match k { + 2 => 3, + 3 | 4 => 2, + _ => panic!("ML-KEM defines k in {{2, 3, 4}}"), + } +} + +const fn du(k: usize) -> usize { + match k { + 2 | 3 => 10, + 4 => 11, + _ => panic!("ML-KEM defines k in {{2, 3, 4}}"), + } +} + +const fn dv(k: usize) -> usize { + match k { + 2 | 3 => 4, + 4 => 5, + _ => panic!("ML-KEM defines k in {{2, 3, 4}}"), + } +} + +impl ParamSet { + /// Number of polynomials in a vector (the `k` of FIPS 203). + pub const K: usize = { + // Evaluating the const is what rejects an unsupported `K`. + let _ = eta1(K); + K + }; + /// Noise parameter for s, e and y. + pub const ETA1: usize = eta1(K); + /// Noise parameter for e1 and e2. + pub const ETA2: usize = 2; + /// Compression bits for the vector u in the ciphertext. + pub const DU: usize = du(K); + /// Compression bits for the polynomial v in the ciphertext. + pub const DV: usize = dv(K); + + /// Encapsulation key: ByteEncode12 of t-hat, then the seed rho. + pub const EK_BYTES: usize = KYBER_POLYBYTES * K + KYBER_SYMBYTES; + /// K-PKE decryption key: ByteEncode12 of s-hat. + pub const DK_PKE_BYTES: usize = KYBER_POLYBYTES * K; + /// ML-KEM decapsulation key: dk_PKE || ek || H(ek) || z (Algorithm 16); + /// H(ek) and z are 32 bytes each. + pub const DK_BYTES: usize = Self::DK_PKE_BYTES + Self::EK_BYTES + 2 * KYBER_SYMBYTES; + /// Ciphertext: c1 (k polynomials of du bits) || c2 (dv bits). + pub const CT_BYTES: usize = K * Self::POLY_COMPRESSED_U_BYTES + Self::POLY_COMPRESSED_V_BYTES; + /// Shared secret. Only the parameter-table test reads it. + #[cfg(test)] + pub const SS_BYTES: usize = KYBER_SSBYTES; + /// Bytes of one compressed polynomial of u. + pub const POLY_COMPRESSED_U_BYTES: usize = encoded_bytes(Self::DU); + /// Bytes of the compressed polynomial v. + pub const POLY_COMPRESSED_V_BYTES: usize = encoded_bytes(Self::DV); + /// Bytes drawn from the PRF for one eta1 noise polynomial (parameter-table test only). + #[cfg(test)] + pub const NOISE1_BYTES: usize = prf_bytes(Self::ETA1); + /// Bytes drawn from the PRF for one eta2 noise polynomial (parameter-table test only). + #[cfg(test)] + pub const NOISE2_BYTES: usize = prf_bytes(Self::ETA2); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::kyber::params::{ + KYBER_1024_CIPHERTEXTBYTES, KYBER_1024_K, KYBER_1024_PUBLICKEYBYTES, + KYBER_1024_SECRETKEYBYTES, KYBER_512_CIPHERTEXTBYTES, KYBER_512_K, + KYBER_512_PUBLICKEYBYTES, KYBER_512_SECRETKEYBYTES, KYBER_768_CIPHERTEXTBYTES, KYBER_768_K, + KYBER_768_PUBLICKEYBYTES, KYBER_768_SECRETKEYBYTES, + }; + + #[test] + fn matches_fips_203_table_2() { + // (k, eta1, eta2, du, dv), FIPS 203 Table 2. + assert_eq!( + ( + MlKem512::K, + MlKem512::ETA1, + MlKem512::ETA2, + MlKem512::DU, + MlKem512::DV + ), + (2, 3, 2, 10, 4) + ); + assert_eq!( + ( + MlKem768::K, + MlKem768::ETA1, + MlKem768::ETA2, + MlKem768::DU, + MlKem768::DV + ), + (3, 2, 2, 10, 4) + ); + assert_eq!( + ( + MlKem1024::K, + MlKem1024::ETA1, + MlKem1024::ETA2, + MlKem1024::DU, + MlKem1024::DV + ), + (4, 2, 2, 11, 5) + ); + } + + #[test] + fn sizes_match_fips_203_table_3() { + // (ek, dk, ciphertext), FIPS 203 Table 3. + assert_eq!( + (MlKem512::EK_BYTES, MlKem512::DK_BYTES, MlKem512::CT_BYTES), + (800, 1632, 768) + ); + assert_eq!( + (MlKem768::EK_BYTES, MlKem768::DK_BYTES, MlKem768::CT_BYTES), + (1184, 2400, 1088) + ); + assert_eq!( + ( + MlKem1024::EK_BYTES, + MlKem1024::DK_BYTES, + MlKem1024::CT_BYTES + ), + (1568, 3168, 1568) + ); + assert_eq!(MlKem768::SS_BYTES, 32); + } + + #[test] + fn sizes_equal_the_legacy_kyber_sizes() { + // Same sizes as Kyber: the two families cannot be told apart by length. + assert_eq!(MlKem512::K, KYBER_512_K); + assert_eq!(MlKem768::K, KYBER_768_K); + assert_eq!(MlKem1024::K, KYBER_1024_K); + assert_eq!(MlKem512::EK_BYTES, KYBER_512_PUBLICKEYBYTES); + assert_eq!(MlKem768::EK_BYTES, KYBER_768_PUBLICKEYBYTES); + assert_eq!(MlKem1024::EK_BYTES, KYBER_1024_PUBLICKEYBYTES); + assert_eq!(MlKem512::DK_BYTES, KYBER_512_SECRETKEYBYTES); + assert_eq!(MlKem768::DK_BYTES, KYBER_768_SECRETKEYBYTES); + assert_eq!(MlKem1024::DK_BYTES, KYBER_1024_SECRETKEYBYTES); + assert_eq!(MlKem512::CT_BYTES, KYBER_512_CIPHERTEXTBYTES); + assert_eq!(MlKem768::CT_BYTES, KYBER_768_CIPHERTEXTBYTES); + assert_eq!(MlKem1024::CT_BYTES, KYBER_1024_CIPHERTEXTBYTES); + } + + #[test] + fn derived_noise_and_compression_sizes() { + assert_eq!(MlKem512::NOISE1_BYTES, 192); + assert_eq!(MlKem768::NOISE1_BYTES, 128); + assert_eq!(MlKem768::NOISE2_BYTES, 128); + assert_eq!(MlKem768::POLY_COMPRESSED_U_BYTES, 320); + assert_eq!(MlKem1024::POLY_COMPRESSED_U_BYTES, 352); + assert_eq!(MlKem768::POLY_COMPRESSED_V_BYTES, 128); + assert_eq!(MlKem1024::POLY_COMPRESSED_V_BYTES, 160); + } +} diff --git a/src/mlkem/polyvec.rs b/src/mlkem/polyvec.rs new file mode 100644 index 0000000..c5664bd --- /dev/null +++ b/src/mlkem/polyvec.rs @@ -0,0 +1,267 @@ +//! Vector of `K` polynomials, generic over `K`, built on the legacy `Poly` and +//! its NTT / reduction / byte-encoding routines (see the table in `mod.rs`). +//! +//! The Montgomery conventions are the legacy ones: `basemul_acc` carries a +//! factor 2^-16 and `invntt_tomont` a factor 2^16, which cancel when the two +//! are composed as in K-PKE. Coefficients must stay reduced between steps; +//! `poly_add` is a plain i16 add. + +use crate::kyber::params::{KYBER_POLYBYTES, KYBER_Q}; +use crate::kyber::poly::{ + poly_add, poly_base_multiply, poly_frombytes, poly_invntt_tomont, poly_ntt, poly_reduce, + poly_tobytes, Poly, +}; +use zeroize::Zeroize; + +/// Shift that moves the sign bit of an `i16` to bit 0, for the conditional add +/// of q on negative representatives. +const SIGN_SHIFT: u32 = i16::BITS - 1; + +#[derive(Clone, Zeroize)] +#[zeroize(drop)] +pub struct PolyVec { + pub vec: [Poly; K], +} + +impl Default for PolyVec { + fn default() -> Self { + PolyVec { + vec: core::array::from_fn(|_| Poly::new()), + } + } +} + +impl PolyVec { + pub fn new() -> Self { + Self::default() + } + + pub fn ntt(&mut self) { + for p in self.vec.iter_mut() { + poly_ntt(p); + } + } + + pub fn invntt_tomont(&mut self) { + for p in self.vec.iter_mut() { + poly_invntt_tomont(p); + } + } + + pub fn reduce(&mut self) { + for p in self.vec.iter_mut() { + poly_reduce(p); + } + } + + pub fn add(&mut self, other: &Self) { + for (a, b) in self.vec.iter_mut().zip(other.vec.iter()) { + poly_add(a, b); + } + } + + /// `r = sum_i a[i] * b[i]` in the NTT domain (inner product of two + /// vectors), reduced. + pub fn basemul_acc(r: &mut Poly, a: &Self, b: &Self) { + let mut t = Poly::new(); + poly_base_multiply(r, &a.vec[0], &b.vec[0]); + for i in 1..K { + poly_base_multiply(&mut t, &a.vec[i], &b.vec[i]); + poly_add(r, &t); + } + poly_reduce(r); + } + + /// ByteEncode12 of every polynomial (FIPS 203 Algorithm 5, d = 12). + /// Coefficients must be in (-q, q). `out` must hold `K * 384` bytes. + pub fn to_bytes(&self, out: &mut [u8]) { + debug_assert_eq!(out.len(), K * KYBER_POLYBYTES); + for (i, p) in self.vec.iter().enumerate() { + poly_tobytes( + &mut out[i * KYBER_POLYBYTES..(i + 1) * KYBER_POLYBYTES], + p.clone(), + ); + } + } + + /// ByteDecode12 of every polynomial (FIPS 203 Algorithm 6, d = 12). + /// Unlike the legacy `poly_frombytes`, every coefficient is reduced mod q + /// as the standard specifies, so `to_bytes(from_bytes(b)) == b` holds + /// exactly when `b` is a canonical encoding (the check of section 7.2). + /// `bytes` must hold `K * 384` bytes. + pub fn from_bytes(bytes: &[u8]) -> Self { + debug_assert_eq!(bytes.len(), K * KYBER_POLYBYTES); + let mut v = Self::new(); + for (i, p) in v.vec.iter_mut().enumerate() { + poly_frombytes(p, &bytes[i * KYBER_POLYBYTES..(i + 1) * KYBER_POLYBYTES]); + for c in p.coeffs.iter_mut() { + // 12-bit input is below 2q, so one conditional subtraction + // reduces it. This is branch-free arithmetic on a sign mask, so it is also + // fine for dk_PKE in K-PKE.Decrypt, which is secret. + let t = *c - KYBER_Q as i16; + *c = t + ((t >> SIGN_SHIFT) & KYBER_Q as i16); + } + } + v + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::kyber::polyvec::*; + + /// Random vector pairs per parameter set. + const ROUNDS: usize = 20; + + struct Prng(u64); + impl Prng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + fn fill(&mut self, v: &mut PolyVec) { + for p in v.vec.iter_mut() { + for c in p.coeffs.iter_mut() { + *c = (self.next() % KYBER_Q as u64) as i16; + } + } + } + } + + fn coeffs(v: &PolyVec) -> Vec { + v.vec.iter().flat_map(|p| p.coeffs).collect() + } + + /// Runs every operation on `PolyVec` and on the legacy per-level type + /// and requires identical coefficients and bytes. + macro_rules! matches_legacy { + ($name:ident, $k:literal, $legacy:ty, $ntt:ident, $invntt:ident, $reduce:ident, + $add:ident, $basemul:ident, $tobytes:ident, $frombytes:ident) => { + #[test] + fn $name() { + let mut rng = Prng(0x1234_5678_9abc_def1 ^ $k); + for _ in 0..ROUNDS { + let (mut a, mut b) = (PolyVec::<$k>::new(), PolyVec::<$k>::new()); + rng.fill(&mut a); + rng.fill(&mut b); + let (mut la, mut lb) = (<$legacy>::new(), <$legacy>::new()); + for i in 0..$k { + la.vec[i].coeffs = a.vec[i].coeffs; + lb.vec[i].coeffs = b.vec[i].coeffs; + } + + a.ntt(); + $ntt(&mut la); + assert_eq!( + coeffs(&a), + la.vec.iter().flat_map(|p| p.coeffs).collect::>() + ); + b.ntt(); + $ntt(&mut lb); + + let (mut r, mut lr) = (Poly::new(), Poly::new()); + PolyVec::basemul_acc(&mut r, &a, &b); + $basemul(&mut lr, &la, &lb); + assert_eq!(r.coeffs, lr.coeffs); + + a.add(&b); + $add(&mut la, &lb); + a.reduce(); + $reduce(&mut la); + assert_eq!( + coeffs(&a), + la.vec.iter().flat_map(|p| p.coeffs).collect::>() + ); + + a.invntt_tomont(); + $invntt(&mut la); + a.reduce(); + $reduce(&mut la); + assert_eq!( + coeffs(&a), + la.vec.iter().flat_map(|p| p.coeffs).collect::>() + ); + + let mut bytes = vec![0u8; $k * KYBER_POLYBYTES]; + let mut lbytes = vec![0u8; $k * KYBER_POLYBYTES]; + a.to_bytes(&mut bytes); + $tobytes(&mut lbytes, &la); + assert_eq!(bytes, lbytes); + + let back = PolyVec::<$k>::from_bytes(&bytes); + let mut lback = <$legacy>::new(); + $frombytes(&mut lback, &bytes); + assert_eq!( + coeffs(&back), + lback.vec.iter().flat_map(|p| p.coeffs).collect::>() + ); + } + } + }; + } + + matches_legacy!( + k2_matches_legacy_512, + 2, + Polyvec512, + polyvec_ntt_512, + polyvec_invntt_tomont_512, + polyvec_reduce_512, + polyvec_add_512, + polyvec_basemul_acc_montgomery_512, + polyvec_tobytes_512, + polyvec_frombytes_512 + ); + matches_legacy!( + k3_matches_legacy_768, + 3, + Polyvec, + polyvec_ntt, + polyvec_invntt_tomont, + polyvec_reduce, + polyvec_add, + polyvec_basemul_acc_montgomery, + polyvec_tobytes, + polyvec_frombytes + ); + matches_legacy!( + k4_matches_legacy_1024, + 4, + Polyvec1024, + polyvec_ntt_1024, + polyvec_invntt_tomont_1024, + polyvec_reduce_1024, + polyvec_add_1024, + polyvec_basemul_acc_montgomery_1024, + polyvec_tobytes_1024, + polyvec_frombytes_1024 + ); + + #[test] + fn from_bytes_reduces_mod_q_like_bytedecode12() { + // 0xFFF is the largest 12-bit value and decodes to 4095 mod q; q itself + // decodes to 0. Two 12-bit values share three bytes (Algorithm 5). + const MAX_12_BIT: u16 = (1 << 12) - 1; + let mut bytes = vec![0u8; 2 * KYBER_POLYBYTES]; + let (c0, c1) = (MAX_12_BIT, KYBER_Q as u16); + bytes[0] = c0 as u8; + bytes[1] = (c0 >> 8) as u8 | (c1 << 4) as u8; + bytes[2] = (c1 >> 4) as u8; + let v = PolyVec::<2>::from_bytes(&bytes); + assert_eq!(v.vec[0].coeffs[0], MAX_12_BIT as i16 - KYBER_Q as i16); + assert_eq!(v.vec[0].coeffs[1], 0); + assert!(v + .vec + .iter() + .flat_map(|p| p.coeffs) + .all(|c| (0..KYBER_Q as i16).contains(&c))); + // Non-canonical input does not round-trip, which is what the + // modulus check of FIPS 203 section 7.2 detects. + let mut again = vec![0u8; 2 * KYBER_POLYBYTES]; + v.to_bytes(&mut again); + assert_ne!(again, bytes); + } +} diff --git a/src/mlkem/sample.rs b/src/mlkem/sample.rs new file mode 100644 index 0000000..daacd18 --- /dev/null +++ b/src/mlkem/sample.rs @@ -0,0 +1,114 @@ +//! SampleNTT (FIPS 203 Algorithm 7) on top of the legacy SHAKE128 XOF and +//! `rej_uniform`. + +use crate::kyber::indcpa::rej_uniform; +use crate::kyber::params::{KYBER_N, KYBER_Q, KYBER_SYMBYTES}; +use crate::kyber::poly::Poly; +use crate::kyber::symmetric::{xof_absorb_bytes, xof_squeeze_blocks, XofState, XOF_BLOCKBYTES}; + +/// Bits of one rejection-sampling candidate (q < 2^12). +const CANDIDATE_BITS: usize = 12; +/// Stream bytes consumed per pair of candidates d1, d2 (Algorithm 7 lines 5-7). +const PAIR_BYTES: usize = 3; +/// Seed rho, then the two index bytes `j || i`. +const INDEX_BYTES: usize = 2; +const SEED_BYTES: usize = KYBER_SYMBYTES; +/// Input of Algorithm 7: `rho || j || i`. +pub(crate) const INPUT_BYTES: usize = SEED_BYTES + INDEX_BYTES; + +/// Blocks squeezed up front; enough for 256 coefficients with high probability. +const NBLOCKS: usize = (CANDIDATE_BITS * KYBER_N / u8::BITS as usize * (1 << CANDIDATE_BITS) + / KYBER_Q + + XOF_BLOCKBYTES) + / XOF_BLOCKBYTES; + +/// Algorithm 7: `b` is `rho || j || i` (a 32-byte seed and two index bytes). +/// The input is public, so the rejection loop may branch on the stream. +pub(crate) fn sample_ntt(b: &[u8; INPUT_BYTES]) -> Poly { + let mut state = XofState::new(); + xof_absorb_bytes( + &mut state, + &b[..SEED_BYTES], + b[SEED_BYTES], + b[SEED_BYTES + 1], + ); + let mut out = Poly::new(); + // Room to carry the unread tail of the previous block (at most PAIR_BYTES - 1). + let mut buf = [0u8; NBLOCKS * XOF_BLOCKBYTES + PAIR_BYTES - 1]; + xof_squeeze_blocks(&mut buf, NBLOCKS, &mut state); + let mut buflen = NBLOCKS * XOF_BLOCKBYTES; + let mut ctr = rej_uniform(&mut out.coeffs, KYBER_N, &buf, buflen); + while ctr < KYBER_N { + // Carry the unread tail (buflen % PAIR_BYTES bytes) in front of the next block + // so no stream byte is skipped. + let off = buflen % PAIR_BYTES; + buf.copy_within(buflen - off..buflen, 0); + xof_squeeze_blocks(&mut buf[off..], 1, &mut state); + buflen = off + XOF_BLOCKBYTES; + ctr += rej_uniform(&mut out.coeffs[ctr..], KYBER_N - ctr, &buf, buflen); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Long enough that the reference sampler never runs out of stream. + const SPEC_STREAM_BLOCKS: usize = 40; + /// Random inputs to compare; enough that some need more than NBLOCKS blocks. + const INPUTS: usize = 3000; + + /// Algorithm 7 transcribed line by line over a long SHAKE128 stream. The + /// literals in d1 and d2 are the ones printed in the standard (lines 6-7). + /// Returns the polynomial and the number of stream bytes consumed. + fn spec_sample_ntt(b: &[u8; INPUT_BYTES]) -> ([i16; KYBER_N], usize) { + let mut state = XofState::new(); + xof_absorb_bytes( + &mut state, + &b[..SEED_BYTES], + b[SEED_BYTES], + b[SEED_BYTES + 1], + ); + let mut stream = vec![0u8; SPEC_STREAM_BLOCKS * XOF_BLOCKBYTES]; + xof_squeeze_blocks(&mut stream, SPEC_STREAM_BLOCKS, &mut state); + let (mut a, mut j, mut pos) = ([0i16; KYBER_N], 0, 0); + while j < KYBER_N { + let c = &stream[pos..pos + 3]; + pos += 3; + let d1 = c[0] as u16 + 256 * (c[1] as u16 % 16); + let d2 = (c[1] as u16) / 16 + 16 * c[2] as u16; + if d1 < KYBER_Q as u16 { + a[j] = d1 as i16; + j += 1; + } + if d2 < KYBER_Q as u16 && j < KYBER_N { + a[j] = d2 as i16; + j += 1; + } + } + (a, pos) + } + + #[test] + fn matches_algorithm_7_including_the_refill_path() { + let mut x = 0x2545_f491_4f6c_dd1du64; + let mut refills = 0; + for _ in 0..INPUTS { + let mut b = [0u8; INPUT_BYTES]; + for byte in b.iter_mut() { + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + *byte = (x >> 24) as u8; + } + let (want, used) = spec_sample_ntt(&b); + if used > NBLOCKS * XOF_BLOCKBYTES { + refills += 1; + } + assert_eq!(sample_ntt(&b).coeffs, want); + } + // The extra-block branch must actually have been exercised. + assert!(refills > 0, "no input needed more than {NBLOCKS} blocks"); + } +} diff --git a/src/mlkem/wycheproof_tests.rs b/src/mlkem/wycheproof_tests.rs new file mode 100644 index 0000000..dc2789b --- /dev/null +++ b/src/mlkem/wycheproof_tests.rs @@ -0,0 +1,219 @@ +//! ML-KEM against the C2SP Wycheproof vectors (repository C2SP/wycheproof, +//! directory `testvectors_v1`, fetched by `scripts/fetch_vectors.py` at the +//! commit pinned in `tests/vectors/manifest.json` and never committed). A +//! missing file fails the test: run the fetch script. +//! +//! The four file kinds and their schemas (`schemas/mlkem_*_test_schema.json` +//! and `schemas/common.json` at the same commit): +//! - `mlkem_N_test.json`: `seed` (`d || z`), `ek`, `c`, `K`. The key is made +//! from the seed; `valid` means decapsulating `c` gives `K`. `invalid` is a +//! seed that is not 64 bytes or a ciphertext of the wrong length. +//! - `mlkem_N_encaps_test.json`: `ek`, `m`, `c`, `K`. `valid` means +//! `Encaps_internal(ek, m)` gives `(K, c)`; `invalid` is an `ek` of the wrong +//! length or one that fails the modulus check (section 7.2). +//! - `mlkem_N_keygen_seed_test.json`: `seed` gives exactly `ek` and `dk`. +//! - `mlkem_N_semi_expanded_decaps_test.json`: `dk`, `ek`, `c`, `K`. `valid` +//! means decapsulating `c` with `dk` gives `K` (an altered ciphertext still +//! gives the implicit-rejection `K`); `invalid` is a wrong length of `dk` or +//! `c`, or a `dk` that fails the hash check (section 7.3). +//! +//! `result` is `valid`, `invalid` or `acceptable` (schemas/common.json). A +//! `valid` case must succeed with the given output, an `invalid` case must be +//! refused, and for an `acceptable` case either is fine, but if it succeeds it +//! must give the output. The flags (`Strcmp`, `MalleableCiphertext`, +//! `ModulusOverflow`, ...) only describe the bug a case targets: none of them +//! changes what the result requires (`notes` in each file). The pinned files +//! have no `acceptable` case; the rule is implemented anyway, so a future pin +//! bump cannot make one pass silently. + +use super::check::check_ek; +use super::kem::{acvp, decaps, encaps}; +use super::params::{MlKem1024, MlKem512, MlKem768, ParamSet}; +use crate::kyber::params::KYBER_SYMBYTES; +use serde_json::Value; + +const WYCHEPROOF_DIR: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/vectors/external/wycheproof/" +); +const HEX_DIGITS_PER_BYTE: usize = 2; +const HEX_RADIX: u32 = 16; +const SEED_BYTES: usize = KYBER_SYMBYTES; +/// The `d || z` seed of Algorithm 19. +const FULL_SEED_BYTES: usize = 2 * SEED_BYTES; + +fn unhex(digits: &str) -> Vec { + (0..digits.len()) + .step_by(HEX_DIGITS_PER_BYTE) + .map(|i| u8::from_str_radix(&digits[i..i + HEX_DIGITS_PER_BYTE], HEX_RADIX).unwrap()) + .collect() +} + +fn field(test: &Value, name: &str) -> Vec { + unhex(test[name].as_str().unwrap_or_else(|| panic!("no {name}"))) +} + +fn halves(seed: &[u8]) -> ([u8; SEED_BYTES], [u8; SEED_BYTES]) { + let (d, z) = seed.split_at(SEED_BYTES); + (d.try_into().unwrap(), z.try_into().unwrap()) +} + +/// Applies the `result` rule of the schema to the outcome of a case: `None` is +/// a refusal, `Some(output)` a success. `expected` is the `K` of the case. +fn judge(tc_id: u64, result: &str, outcome: Option>, expected: &[u8]) { + match (result, outcome) { + ("valid", Some(output)) => assert_eq!(output, expected, "tcId {tc_id}"), + ("valid", None) => panic!("tcId {tc_id}: valid case was refused"), + ("invalid", outcome) => assert!(outcome.is_none(), "tcId {tc_id}: invalid case accepted"), + ("acceptable", Some(output)) if !expected.is_empty() => { + assert_eq!(output, expected, "tcId {tc_id}") + } + ("acceptable", _) => {} + (other, _) => panic!("tcId {tc_id}: unknown result {other}"), + } +} + +/// Runs `check::(test) -> Option>` on every case of a file and +/// judges it. Returns the number of cases run, which must be `numberOfTests`. +fn run_file( + file: &str, + schema: &str, + parameter_set: &str, + check: fn(&Value) -> Option>, + expected_field: &str, +) -> usize { + let path = format!("{WYCHEPROOF_DIR}{file}"); + let text = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("{path}: {e}; run scripts/fetch_vectors.py")); + let doc: Value = serde_json::from_str(&text).expect("Wycheproof file is JSON"); + assert_eq!(doc["algorithm"], "ML-KEM", "{file}"); + assert_eq!(doc["schema"], schema, "{file}"); + let mut cases = 0; + for group in doc["testGroups"].as_array().unwrap() { + assert_eq!(group["parameterSet"], parameter_set, "{file}"); + for test in group["tests"].as_array().unwrap() { + let expected = test + .get(expected_field) + .map_or(Vec::new(), |_| field(test, expected_field)); + judge( + test["tcId"].as_u64().unwrap(), + test["result"].as_str().unwrap(), + check(test), + &expected, + ); + cases += 1; + } + } + assert_eq!( + cases as u64, + doc["numberOfTests"].as_u64().unwrap(), + "{file}: numberOfTests" + ); + cases +} + +/// `mlkem_N_test.json`. A seed that is not `d || z` cannot be passed to the +/// typed Rust API (two 32-byte arrays), and the Python test covers it through +/// `seed=`; here such a case counts as refused, as the type system does. +fn seed_case(test: &Value) -> Option> { + let seed = field(test, "seed"); + if seed.len() != FULL_SEED_BYTES { + return None; + } + let (d, z) = halves(&seed); + let (ek, dk) = acvp::keygen_internal::(&d, &z); + assert_eq!(ek, field(test, "ek"), "tcId {}", test["tcId"]); + decaps::(&dk, &field(test, "c")).ok().map(|k| k.to_vec()) +} + +/// `mlkem_N_encaps_test.json`: the public `encaps` refuses a bad `ek` before it +/// draws `m`; for a good one `Encaps_internal(ek, m)` must give `(K, c)`. +fn encaps_case(test: &Value) -> Option> { + let ek = field(test, "ek"); + let refused = encaps::(&ek).is_err(); + assert_eq!( + refused, + check_ek::(&ek).is_err(), + "tcId {}", + test["tcId"] + ); + if refused { + return None; + } + let m: [u8; SEED_BYTES] = field(test, "m").try_into().expect("32-byte m"); + let (k, c) = acvp::encaps_internal::(&ek, &m).unwrap(); + assert_eq!(c, field(test, "c"), "tcId {}", test["tcId"]); + Some(k.to_vec()) +} + +/// `mlkem_N_keygen_seed_test.json`: no refusal is possible, `K` is the `ek`. +fn keygen_case(test: &Value) -> Option> { + let (d, z) = halves(&field(test, "seed")); + let (ek, dk) = acvp::keygen_internal::(&d, &z); + assert_eq!(dk.to_vec(), field(test, "dk"), "tcId {}", test["tcId"]); + Some(ek) +} + +/// `mlkem_N_semi_expanded_decaps_test.json`. +fn decaps_case(test: &Value) -> Option> { + let (dk, c) = (field(test, "dk"), field(test, "c")); + let output = decaps::(&dk, &c).ok().map(|k| k.to_vec()); + if output.is_some() { + // The ek the file lists is the one embedded in dk (Algorithm 16 step 3). + let embedded = &dk[ParamSet::::DK_PKE_BYTES..][..ParamSet::::EK_BYTES]; + assert_eq!(embedded, field(test, "ek"), "tcId {}", test["tcId"]); + } + output +} + +fn run_level(size: usize) -> [usize; 4] { + let set = format!("ML-KEM-{size}"); + let name = |kind: &str| format!("mlkem_{size}_{kind}.json"); + [ + run_file::( + &name("test"), + "mlkem_test_schema.json", + &set, + seed_case::, + "K", + ), + run_file::( + &name("encaps_test"), + "mlkem_encaps_test_schema.json", + &set, + encaps_case::, + "K", + ), + run_file::( + &name("keygen_seed_test"), + "mlkem_keygen_seed_test_schema.json", + &set, + keygen_case::, + "ek", + ), + run_file::( + &name("semi_expanded_decaps_test"), + "mlkem_semi_expanded_decaps_test_schema.json", + &set, + decaps_case::, + "K", + ), + ] +} + +// The case counts are those of the pinned files (`numberOfTests`), so a file +// that lost cases would fail here as well as on its hash. +#[test] +fn wycheproof_ml_kem_512() { + assert_eq!(run_level::<{ MlKem512::K }>(512), [200, 261, 100, 9]); +} + +#[test] +fn wycheproof_ml_kem_768() { + assert_eq!(run_level::<{ MlKem768::K }>(768), [201, 265, 100, 9]); +} + +#[test] +fn wycheproof_ml_kem_1024() { + assert_eq!(run_level::<{ MlKem1024::K }>(1024), [202, 269, 100, 9]); +} diff --git a/src/pymlkem.rs b/src/pymlkem.rs new file mode 100644 index 0000000..4b859a7 --- /dev/null +++ b/src/pymlkem.rs @@ -0,0 +1,128 @@ +//! PyO3 glue for ML-KEM: one keygen, encaps and decaps function per parameter +//! set, calling the public functions of `crate::mlkem::kem`. The `acvp` +//! feature and `kem::acvp` are never used here (FIPS 203 section 3.3). +//! +//! Every `KyberError` becomes an instance of a Python subclass of +//! `pykyber.KyberError`, looked up by name in `pykyber._error` when the error +//! is raised. Secrets reach Python as `bytes` built straight from the Rust +//! slice, so the only copy besides the zeroized Rust one is the Python object, +//! which cannot be wiped. + +use crate::kyber::error::KyberError; +use crate::kyber::params::KYBER_SYMBYTES; +use crate::mlkem::kem; +use crate::mlkem::params::{MlKem1024, MlKem512, MlKem768}; +use pyo3::prelude::*; +use pyo3::types::PyBytes; + +/// Python module that defines the exception classes. +const ERROR_MODULE: &str = "pykyber._error"; +/// The seed is `d || z`, 32 bytes each (FIPS 203 Algorithm 19, steps 1-2). +const SEED_BYTES: usize = 2 * KYBER_SYMBYTES; + +/// Name of the `KyberError` subclass in `ERROR_MODULE` for `error`. +fn error_class(error: &KyberError) -> &'static str { + match error { + KyberError::InvalidInput { .. } => "InvalidInputError", + KyberError::Decapsulation => "DecapsulationError", + KyberError::RandomBytesGeneration => "RandomnessError", + KyberError::InvalidEncapsulationKey => "InvalidEncapsulationKeyError", + KyberError::InvalidDecapsulationKey => "InvalidDecapsulationKeyError", + } +} + +pub(crate) fn to_py_err(py: Python<'_>, error: KyberError) -> PyErr { + py.import(ERROR_MODULE) + .and_then(|module| module.getattr(error_class(&error))) + .and_then(|class| class.call1((error.to_string(),))) + .map_or_else(|failure| failure, PyErr::from_value) +} + +/// Split the 64-byte seed into `(d, z)`. +fn split_seed(seed: &[u8]) -> Result<(&[u8; KYBER_SYMBYTES], &[u8; KYBER_SYMBYTES]), KyberError> { + let wrong_size = || KyberError::invalid_input("seed", SEED_BYTES, seed.len()); + if seed.len() != SEED_BYTES { + return Err(wrong_size()); + } + let (d, z) = seed.split_at(KYBER_SYMBYTES); + Ok(( + d.try_into().map_err(|_| wrong_size())?, + z.try_into().map_err(|_| wrong_size())?, + )) +} + +type BytesPair<'py> = (Bound<'py, PyBytes>, Bound<'py, PyBytes>); + +/// `(ek, dk)`, from the seed when there is one and from the OS generator otherwise. +fn keygen<'py, const K: usize>(py: Python<'py>, seed: Option<&[u8]>) -> PyResult> { + let (ek, dk) = match seed { + Some(seed) => { + let (d, z) = split_seed(seed).map_err(|e| to_py_err(py, e))?; + kem::keygen_from_seed::(d, z) + } + None => kem::keygen::().map_err(|e| to_py_err(py, e))?, + }; + Ok((PyBytes::new(py, &ek), PyBytes::new(py, &dk))) +} + +/// `(c, K)`: ciphertext first, like the legacy functions. +fn encaps<'py, const K: usize>(py: Python<'py>, ek: &[u8]) -> PyResult> { + let (shared_secret, ciphertext) = kem::encaps::(ek).map_err(|e| to_py_err(py, e))?; + Ok(( + PyBytes::new(py, &ciphertext), + PyBytes::new(py, &shared_secret[..]), + )) +} + +fn decaps<'py, const K: usize>( + py: Python<'py>, + ciphertext: &[u8], + dk: &[u8], +) -> PyResult> { + let shared_secret = kem::decaps::(dk, ciphertext).map_err(|e| to_py_err(py, e))?; + Ok(PyBytes::new(py, &shared_secret[..])) +} + +/// The three `#[pyfunction]`s of one parameter set. +macro_rules! parameter_set { + ($keygen:ident, $encaps:ident, $decaps:ident, $params:ty) => { + #[pyfunction] + #[pyo3(signature = (seed=None))] + pub fn $keygen<'py>(py: Python<'py>, seed: Option<&[u8]>) -> PyResult> { + keygen::<{ <$params>::K }>(py, seed) + } + + #[pyfunction] + pub fn $encaps<'py>(py: Python<'py>, ek: &[u8]) -> PyResult> { + encaps::<{ <$params>::K }>(py, ek) + } + + #[pyfunction] + pub fn $decaps<'py>( + py: Python<'py>, + ciphertext: &[u8], + dk: &[u8], + ) -> PyResult> { + decaps::<{ <$params>::K }>(py, ciphertext, dk) + } + }; +} + +parameter_set!( + _mlkem_512_keygen, + _mlkem_512_encaps, + _mlkem_512_decaps, + MlKem512 +); +parameter_set!( + _mlkem_768_keygen, + _mlkem_768_encaps, + _mlkem_768_decaps, + MlKem768 +); +parameter_set!( + _mlkem_1024_keygen, + _mlkem_1024_encaps, + _mlkem_1024_decaps, + MlKem1024 +); diff --git a/tests/legacy_decaps_rejection.rs b/tests/legacy_decaps_rejection.rs new file mode 100644 index 0000000..e59debd --- /dev/null +++ b/tests/legacy_decaps_rejection.rs @@ -0,0 +1,130 @@ +//! Implicit rejection in the legacy (round-3) decapsulation: a ciphertext that +//! does not re-encrypt to itself must give `KDF(z || H(c))` (not an error, and +//! not the real shared secret). The expected value is derived here from `z` and +//! `c` with the public hash functions, independently of the code under test, so +//! it covers `verify`, `cmov` and the rest of the decapsulation tail. + +use pykyber::kyber::fips202::{sha3_256, shake256}; +use pykyber::kyber::params::*; +use pykyber::kyber::{ + crypto_kem_dec, crypto_kem_dec_1024, crypto_kem_dec_512, crypto_kem_enc, crypto_kem_enc_1024, + crypto_kem_enc_512, crypto_kem_keypair, crypto_kem_keypair_1024, crypto_kem_keypair_512, +}; +use rand_core::{CryptoRng, Error, RngCore}; + +/// Seeded paths never draw randomness; this generator must not be asked to. +struct NoRandomness; + +impl RngCore for NoRandomness { + fn next_u32(&mut self) -> u32 { + unreachable!() + } + fn next_u64(&mut self) -> u64 { + unreachable!() + } + fn fill_bytes(&mut self, _dest: &mut [u8]) { + unreachable!() + } + fn try_fill_bytes(&mut self, _dest: &mut [u8]) -> Result<(), Error> { + unreachable!() + } +} + +impl CryptoRng for NoRandomness {} + +/// Fixed seeds `d`, `z` and `m` (arbitrary test data). +const D_SEED: [u8; KYBER_SYMBYTES] = [0x11; KYBER_SYMBYTES]; +const Z_SEED: [u8; KYBER_SYMBYTES] = [0x22; KYBER_SYMBYTES]; +const M_SEED: [u8; KYBER_SYMBYTES] = [0x33; KYBER_SYMBYTES]; + +/// `KDF(z || H(c))`, the shared secret of an implicitly rejected ciphertext. +fn rejection_secret(z: &[u8], ct: &[u8]) -> [u8; KYBER_SSBYTES] { + let mut h = [0u8; KYBER_SYMBYTES]; + sha3_256(&mut h, ct, ct.len()); + let mut input = [0u8; 2 * KYBER_SYMBYTES]; + input[..KYBER_SYMBYTES].copy_from_slice(z); + input[KYBER_SYMBYTES..].copy_from_slice(&h); + let mut ss = [0u8; KYBER_SSBYTES]; + shake256(&mut ss, KYBER_SSBYTES, &input, input.len()); + ss +} + +type Decaps = fn(&mut [u8], &[u8], &[u8]) -> Result<(), pykyber::kyber::KyberError>; + +/// Byte positions to damage: the first, the last and a few in between, +/// covering the compressed vector part and the compressed `v` part. +fn damaged_positions(ct_len: usize) -> [usize; 5] { + [0, 1, ct_len / 3, ct_len - KYBER_SYMBYTES, ct_len - 1] +} + +fn check(pk: &[u8], sk: &[u8], ct: &[u8], real_ss: &[u8], dec: Decaps) { + let z = &sk[sk.len() - KYBER_SYMBYTES..]; + + let mut ss = [0u8; KYBER_SSBYTES]; + dec(&mut ss, ct, sk).unwrap(); + assert_eq!(ss, real_ss, "valid ciphertext (pk length {})", pk.len()); + + for pos in damaged_positions(ct.len()) { + for bit in [0u8, 7u8] { + let mut bad = ct.to_vec(); + bad[pos] ^= 1 << bit; + let mut got = [0u8; KYBER_SSBYTES]; + dec(&mut got, &bad, sk).expect("implicit rejection is not an error"); + assert_ne!(got, real_ss, "byte {pos} bit {bit} gave the real secret"); + assert_eq!( + got, + rejection_secret(z, &bad), + "byte {pos} bit {bit} of a ciphertext of {} bytes", + ct.len() + ); + } + } +} + +#[test] +fn kyber_512_rejects_implicitly() { + let mut pk = vec![0u8; KYBER_512_PUBLICKEYBYTES]; + let mut sk = vec![0u8; KYBER_512_SECRETKEYBYTES]; + crypto_kem_keypair_512( + &mut pk, + &mut sk, + &mut NoRandomness, + Some((&D_SEED, &Z_SEED)), + ) + .unwrap(); + let (mut ct, mut ss) = (vec![0u8; KYBER_512_CIPHERTEXTBYTES], [0u8; KYBER_SSBYTES]); + crypto_kem_enc_512(&mut ct, &mut ss, &pk, &mut NoRandomness, Some(&M_SEED)).unwrap(); + check(&pk, &sk, &ct, &ss, crypto_kem_dec_512); +} + +#[test] +fn kyber_768_rejects_implicitly() { + let mut pk = vec![0u8; KYBER_PUBLICKEYBYTES]; + let mut sk = vec![0u8; KYBER_SECRETKEYBYTES]; + crypto_kem_keypair( + &mut pk, + &mut sk, + &mut NoRandomness, + Some((&D_SEED, &Z_SEED)), + ) + .unwrap(); + let (mut ct, mut ss) = (vec![0u8; KYBER_CIPHERTEXTBYTES], [0u8; KYBER_SSBYTES]); + crypto_kem_enc(&mut ct, &mut ss, &pk, &mut NoRandomness, Some(&M_SEED)).unwrap(); + check(&pk, &sk, &ct, &ss, crypto_kem_dec); +} + +#[test] +fn kyber_1024_rejects_implicitly() { + let mut pk = vec![0u8; KYBER_1024_PUBLICKEYBYTES]; + let mut sk = vec![0u8; KYBER_1024_SECRETKEYBYTES]; + crypto_kem_keypair_1024( + &mut pk, + &mut sk, + &mut NoRandomness, + Some((&D_SEED, &Z_SEED)), + ) + .unwrap(); + let (mut ct, mut ss) = (vec![0u8; KYBER_1024_CIPHERTEXTBYTES], [0u8; KYBER_SSBYTES]); + crypto_kem_enc_1024(&mut ct, &mut ss, &pk, &mut NoRandomness, Some(&M_SEED)).unwrap(); + check(&pk, &sk, &ct, &ss, crypto_kem_dec_1024); +} diff --git a/tests/legacy_length_checks.rs b/tests/legacy_length_checks.rs new file mode 100644 index 0000000..8405012 --- /dev/null +++ b/tests/legacy_length_checks.rs @@ -0,0 +1,108 @@ +//! The legacy keypair and encapsulation functions index caller-supplied buffers +//! and seeds, so they must reject a wrong length with `InvalidInput` instead of +//! panicking (decapsulation already did). + +use pykyber::kyber::error::KyberError; +use pykyber::kyber::params::*; +use pykyber::kyber::{ + crypto_kem_enc, crypto_kem_enc_1024, crypto_kem_enc_512, crypto_kem_keypair, + crypto_kem_keypair_1024, crypto_kem_keypair_512, derive, +}; +use rand::rngs::OsRng; + +fn is_invalid_input(result: Result, param: &str) -> bool { + matches!(result, Err(KyberError::InvalidInput { param: p, .. }) if p == param) +} + +/// `(public key length, secret key length, ciphertext length, keypair, encapsulate)` per level. +type Keypair = + fn(&mut [u8], &mut [u8], &mut OsRng, Option<(&[u8], &[u8])>) -> Result<(), KyberError>; +type Encaps = fn(&mut [u8], &mut [u8], &[u8], &mut OsRng, Option<&[u8]>) -> Result<(), KyberError>; + +fn levels() -> [(usize, usize, usize, Keypair, Encaps); 3] { + [ + ( + KYBER_512_PUBLICKEYBYTES, + KYBER_512_SECRETKEYBYTES, + KYBER_512_CIPHERTEXTBYTES, + crypto_kem_keypair_512::, + crypto_kem_enc_512::, + ), + ( + KYBER_PUBLICKEYBYTES, + KYBER_SECRETKEYBYTES, + KYBER_CIPHERTEXTBYTES, + crypto_kem_keypair::, + crypto_kem_enc::, + ), + ( + KYBER_1024_PUBLICKEYBYTES, + KYBER_1024_SECRETKEYBYTES, + KYBER_1024_CIPHERTEXTBYTES, + crypto_kem_keypair_1024::, + crypto_kem_enc_1024::, + ), + ] +} + +#[test] +fn keypair_rejects_wrong_buffer_and_seed_lengths() { + let half = [0u8; KYBER_SYMBYTES]; + for (pk_len, sk_len, _, keypair, _) in levels() { + let (mut pk, mut sk) = (vec![0u8; pk_len], vec![0u8; sk_len]); + let mut rng = OsRng; + assert!(is_invalid_input( + keypair(&mut pk[1..], &mut sk, &mut rng, None), + "pk" + )); + assert!(is_invalid_input( + keypair(&mut pk, &mut sk[1..], &mut rng, None), + "sk" + )); + assert!(is_invalid_input( + keypair(&mut pk, &mut sk, &mut rng, Some((&half[1..], &half))), + "seed d" + )); + assert!(is_invalid_input( + keypair(&mut pk, &mut sk, &mut rng, Some((&half, &[]))), + "seed z" + )); + // The right sizes still work, seeded and not. + keypair(&mut pk, &mut sk, &mut rng, Some((&half, &half))).unwrap(); + keypair(&mut pk, &mut sk, &mut rng, None).unwrap(); + } +} + +#[test] +fn encapsulate_rejects_wrong_buffer_and_seed_lengths() { + let m = [0u8; KYBER_SYMBYTES]; + for (pk_len, sk_len, ct_len, keypair, enc) in levels() { + let (mut pk, mut sk) = (vec![0u8; pk_len], vec![0u8; sk_len]); + keypair(&mut pk, &mut sk, &mut OsRng, None).unwrap(); + let (mut ct, mut ss) = (vec![0u8; ct_len], [0u8; KYBER_SSBYTES]); + let mut rng = OsRng; + assert!(is_invalid_input( + enc(&mut ct[1..], &mut ss, &pk, &mut rng, None), + "ct" + )); + assert!(is_invalid_input( + enc(&mut ct, &mut ss[1..], &pk, &mut rng, None), + "ss" + )); + assert!(is_invalid_input( + enc(&mut ct, &mut ss, &pk[1..], &mut rng, None), + "pk" + )); + assert!(is_invalid_input( + enc(&mut ct, &mut ss, &pk, &mut rng, Some(&m[1..])), + "seed" + )); + enc(&mut ct, &mut ss, &pk, &mut rng, Some(&m)).unwrap(); + } +} + +#[test] +fn derive_rejects_a_wrong_seed_length() { + assert!(is_invalid_input(derive(&[0u8; 63]).map(|_| ()), "seed")); + assert!(derive(&[0u8; 64]).is_ok()); +} diff --git a/tests/legacy_rng_failure.rs b/tests/legacy_rng_failure.rs new file mode 100644 index 0000000..e14c4c7 --- /dev/null +++ b/tests/legacy_rng_failure.rs @@ -0,0 +1,122 @@ +//! A failing random generator must surface as `KyberError::RandomBytesGeneration` +//! from every legacy keypair and encapsulation function (no panic, no fallback +//! to another source), and a failure after the IND-CPA secret key was written must +//! not leave that key in the caller's buffer. + +use pykyber::kyber::error::KyberError; +use pykyber::kyber::params::*; +use pykyber::kyber::{ + crypto_kem_enc, crypto_kem_enc_1024, crypto_kem_enc_512, crypto_kem_keypair, + crypto_kem_keypair_1024, crypto_kem_keypair_512, keypair, +}; +use rand_core::{CryptoRng, Error, RngCore}; + +/// Byte every successful fill writes. +const FILL: u8 = 0x42; +/// Keypair generation asks the generator twice: `d` first, then `z`. +const CALLS_BEFORE_Z: usize = 1; + +/// Generator that fills `ok_calls` requests and then reports a failure. +struct FailingRng { + ok_calls: usize, +} + +impl RngCore for FailingRng { + fn next_u32(&mut self) -> u32 { + panic!("the legacy code must use try_fill_bytes"); + } + fn next_u64(&mut self) -> u64 { + panic!("the legacy code must use try_fill_bytes"); + } + fn fill_bytes(&mut self, _dest: &mut [u8]) { + panic!("the legacy code must use try_fill_bytes"); + } + fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), Error> { + if self.ok_calls == 0 { + return Err(Error::new("simulated failure of the OS generator")); + } + self.ok_calls -= 1; + dest.fill(FILL); + Ok(()) + } +} + +impl CryptoRng for FailingRng {} + +fn assert_randomness_error(result: Result) { + assert_eq!(result.unwrap_err(), KyberError::RandomBytesGeneration); +} + +type KeypairFn = + fn(&mut [u8], &mut [u8], &mut FailingRng, Option<(&[u8], &[u8])>) -> Result<(), KyberError>; + +/// `(public key length, secret key length, function)` for each level. +fn keypair_levels() -> [(usize, usize, KeypairFn); 3] { + [ + ( + KYBER_512_PUBLICKEYBYTES, + KYBER_512_SECRETKEYBYTES, + crypto_kem_keypair_512::, + ), + ( + KYBER_PUBLICKEYBYTES, + KYBER_SECRETKEYBYTES, + crypto_kem_keypair::, + ), + ( + KYBER_1024_PUBLICKEYBYTES, + KYBER_1024_SECRETKEYBYTES, + crypto_kem_keypair_1024::, + ), + ] +} + +#[test] +fn keypair_fails_when_d_cannot_be_drawn() { + for (pk_len, sk_len, run) in keypair_levels() { + let (mut pk, mut sk) = (vec![0u8; pk_len], vec![0u8; sk_len]); + let mut rng = FailingRng { ok_calls: 0 }; + assert_randomness_error(run(&mut pk, &mut sk, &mut rng, None)); + } +} + +#[test] +fn keypair_scrubs_the_secret_key_when_z_cannot_be_drawn() { + for (pk_len, sk_len, run) in keypair_levels() { + let (mut pk, mut sk) = (vec![0u8; pk_len], vec![0u8; sk_len]); + let mut rng = FailingRng { + ok_calls: CALLS_BEFORE_Z, + }; + assert_randomness_error(run(&mut pk, &mut sk, &mut rng, None)); + assert!( + sk.iter().all(|&b| b == 0), + "sk length {sk_len} not scrubbed" + ); + } +} + +#[test] +fn encapsulation_fails_when_m_cannot_be_drawn() { + let mut rng = FailingRng { ok_calls: 0 }; + let mut ss = [0u8; KYBER_SSBYTES]; + + let pk = [0u8; KYBER_512_PUBLICKEYBYTES]; + let mut ct = [0u8; KYBER_512_CIPHERTEXTBYTES]; + assert_randomness_error(crypto_kem_enc_512(&mut ct, &mut ss, &pk, &mut rng, None)); + + let pk = [0u8; KYBER_PUBLICKEYBYTES]; + let mut ct = [0u8; KYBER_CIPHERTEXTBYTES]; + assert_randomness_error(crypto_kem_enc(&mut ct, &mut ss, &pk, &mut rng, None)); + + let pk = [0u8; KYBER_1024_PUBLICKEYBYTES]; + let mut ct = [0u8; KYBER_1024_CIPHERTEXTBYTES]; + assert_randomness_error(crypto_kem_enc_1024(&mut ct, &mut ss, &pk, &mut rng, None)); +} + +#[test] +fn safe_api_reports_the_failure() { + assert_randomness_error(keypair(&mut FailingRng { ok_calls: 0 })); + assert_randomness_error(keypair(&mut FailingRng { + ok_calls: CALLS_BEFORE_Z, + })); +} diff --git a/tests/legacy_vectors.rs b/tests/legacy_vectors.rs new file mode 100644 index 0000000..c66ea31 --- /dev/null +++ b/tests/legacy_vectors.rs @@ -0,0 +1,191 @@ +//! Golden-vector freeze for legacy Kyber: pykyber v1.0.5 must keep producing +//! byte-identical output. The vector file `tests/vectors/legacy-kyber-v1.0.5.json` +//! is append-only (captured from the pinned PyPI wheel and a worktree at tag +//! v1.0.5 by tests/vectors/generate.py). If these tests fail, the bug is in +//! current code; the vectors are never regenerated or edited to pass. + +use pykyber::kyber::error::KyberError; +use pykyber::kyber::kem::{ + crypto_kem_dec, crypto_kem_dec_1024, crypto_kem_dec_512, crypto_kem_enc, crypto_kem_enc_1024, + crypto_kem_enc_512, crypto_kem_keypair, crypto_kem_keypair_1024, crypto_kem_keypair_512, +}; +use pykyber::kyber::params::{ + KYBER_1024_CIPHERTEXTBYTES, KYBER_1024_PUBLICKEYBYTES, KYBER_1024_SECRETKEYBYTES, + KYBER_512_CIPHERTEXTBYTES, KYBER_512_PUBLICKEYBYTES, KYBER_512_SECRETKEYBYTES, + KYBER_768_CIPHERTEXTBYTES, KYBER_768_PUBLICKEYBYTES, KYBER_768_SECRETKEYBYTES, KYBER_SSBYTES, +}; +use rand_core::{CryptoRng, RngCore}; +use serde::Deserialize; +use std::fs; + +const VECTORS_FILE: &str = "tests/vectors/legacy-kyber-v1.0.5.json"; + +/// Seed layout: key seed is `d || z`, encapsulation seed is `m`. +const SEED_HALF_BYTES: usize = 32; +const KEY_SEED_BYTES: usize = 2 * SEED_HALF_BYTES; +const ENC_SEED_BYTES: usize = 32; + +/// Three key seeds per parameter set, three parameter sets (see generate.py). +const SEEDS_PER_SET: usize = 3; +const PARAMETER_SETS: usize = 3; + +/// Panicking RNG: the seeded v1.0.5 paths must not consume randomness. These +/// vectors freeze the seeded paths only; any rng use here is a bug. +struct PanicRng; +impl RngCore for PanicRng { + fn next_u32(&mut self) -> u32 { + panic!("rng consumed on a seeded path") + } + fn next_u64(&mut self) -> u64 { + panic!("rng consumed on a seeded path") + } + fn try_fill_bytes(&mut self, _dest: &mut [u8]) -> Result<(), rand_core::Error> { + panic!("rng consumed on a seeded path") + } + fn fill_bytes(&mut self, _dest: &mut [u8]) { + panic!("rng consumed on a seeded path") + } +} +impl CryptoRng for PanicRng {} + +fn hex_decode(s: &str) -> Vec { + assert_eq!(s.len() % 2, 0, "hex string must have even length"); + (0..s.len() / 2) + .map(|i| u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).expect("hex digit")) + .collect() +} + +#[derive(Deserialize)] +struct GoldenMeta { + append_only: bool, +} + +#[derive(Deserialize)] +struct GoldenFile { + meta: GoldenMeta, + vectors: Vec, +} + +#[derive(Deserialize)] +struct GoldenVector { + parameter_set: String, + key_seed_hex: String, + enc_seed_hex: String, + keypair_public_key_hex: String, + keypair_secret_key_hex: String, + ciphertext_hex: String, + shared_secret_encaps_hex: String, + shared_secret_decaps_hex: String, +} + +type KeypairFn = + fn(&mut [u8], &mut [u8], &mut PanicRng, Option<(&[u8], &[u8])>) -> Result<(), KyberError>; +type EncFn = + fn(&mut [u8], &mut [u8], &[u8], &mut PanicRng, Option<&[u8]>) -> Result<(), KyberError>; +type DecFn = fn(&mut [u8], &[u8], &[u8]) -> Result<(), KyberError>; + +/// The legacy entry points and buffer sizes of one parameter set. +struct Suite { + pk_bytes: usize, + sk_bytes: usize, + ct_bytes: usize, + keypair: KeypairFn, + enc: EncFn, + dec: DecFn, +} + +fn suite(parameter_set: &str) -> Suite { + match parameter_set { + "512" => Suite { + pk_bytes: KYBER_512_PUBLICKEYBYTES, + sk_bytes: KYBER_512_SECRETKEYBYTES, + ct_bytes: KYBER_512_CIPHERTEXTBYTES, + keypair: crypto_kem_keypair_512::, + enc: crypto_kem_enc_512::, + dec: crypto_kem_dec_512, + }, + "768" => Suite { + pk_bytes: KYBER_768_PUBLICKEYBYTES, + sk_bytes: KYBER_768_SECRETKEYBYTES, + ct_bytes: KYBER_768_CIPHERTEXTBYTES, + keypair: crypto_kem_keypair::, + enc: crypto_kem_enc::, + dec: crypto_kem_dec, + }, + "1024" => Suite { + pk_bytes: KYBER_1024_PUBLICKEYBYTES, + sk_bytes: KYBER_1024_SECRETKEYBYTES, + ct_bytes: KYBER_1024_CIPHERTEXTBYTES, + keypair: crypto_kem_keypair_1024::, + enc: crypto_kem_enc_1024::, + dec: crypto_kem_dec_1024, + }, + other => panic!("unknown parameter set {other:?}"), + } +} + +fn check_vector(v: &GoldenVector, index: usize) { + let at = format!("legacy vector #{index} (Kyber-{})", v.parameter_set); + let key_seed = hex_decode(&v.key_seed_hex); + let enc_seed = hex_decode(&v.enc_seed_hex); + assert_eq!(key_seed.len(), KEY_SEED_BYTES, "{at}: key seed length"); + assert_eq!(enc_seed.len(), ENC_SEED_BYTES, "{at}: enc seed length"); + let (d, z) = key_seed.split_at(SEED_HALF_BYTES); + + let s = suite(&v.parameter_set); + let mut pk = vec![0u8; s.pk_bytes]; + let mut sk = vec![0u8; s.sk_bytes]; + (s.keypair)(&mut pk, &mut sk, &mut PanicRng, Some((d, z))).expect("keygen"); + assert_eq!( + pk, + hex_decode(&v.keypair_public_key_hex), + "{at}: public key differs from v1.0.5" + ); + assert_eq!( + sk, + hex_decode(&v.keypair_secret_key_hex), + "{at}: secret key differs from v1.0.5" + ); + + let mut ct = vec![0u8; s.ct_bytes]; + let mut ss_enc = vec![0u8; KYBER_SSBYTES]; + (s.enc)(&mut ct, &mut ss_enc, &pk, &mut PanicRng, Some(&enc_seed)) + .expect("seeded encapsulation"); + assert_eq!( + ct, + hex_decode(&v.ciphertext_hex), + "{at}: ciphertext differs from v1.0.5" + ); + assert_eq!( + ss_enc, + hex_decode(&v.shared_secret_encaps_hex), + "{at}: encapsulated shared secret differs from v1.0.5" + ); + + let mut ss_dec = vec![0u8; KYBER_SSBYTES]; + (s.dec)(&mut ss_dec, &ct, &sk).expect("decapsulation"); + assert_eq!( + ss_dec, + hex_decode(&v.shared_secret_decaps_hex), + "{at}: decapsulation differs from v1.0.5" + ); +} + +#[test] +fn legacy_kyber_v1_0_5_outputs_are_frozen() { + let path = format!("{}/{}", env!("CARGO_MANIFEST_DIR"), VECTORS_FILE); + let text = fs::read_to_string(&path).expect("read golden vector file"); + let file: GoldenFile = serde_json::from_str(&text).expect("parse golden vector file"); + assert!( + file.meta.append_only, + "golden vector file meta must state append_only=true" + ); + assert_eq!( + file.vectors.len(), + SEEDS_PER_SET * PARAMETER_SETS, + "expected {SEEDS_PER_SET} seeds x {PARAMETER_SETS} parameter sets" + ); + for (index, vector) in file.vectors.iter().enumerate() { + check_vector(vector, index); + } +} diff --git a/tests/mlkem_shared_primitives.rs b/tests/mlkem_shared_primitives.rs new file mode 100644 index 0000000..2cfc140 --- /dev/null +++ b/tests/mlkem_shared_primitives.rs @@ -0,0 +1,578 @@ +//! WP-2: checks that the legacy primitives ML-KEM wants to reuse agree with +//! the FIPS 203 / FIPS 202 text. Each reference below is a literal, slow +//! transcription of the algorithm in the standard (integers mod q, bit +//! arrays), written from the PDF and not from the code under test. +//! +//! Source: FIPS 203 (final, published 2024-08-13), +//! . +//! The SHA-3/SHAKE expected values were produced with Python's hashlib +//! (OpenSSL), an independent implementation; the input is the byte pattern +//! `i % 251` for `i` in `0..len`. + +use pykyber::kyber::cbd::{cbd2, cbd3}; +use pykyber::kyber::fips202::{sha3_256, sha3_512, shake256}; +use pykyber::kyber::ntt::{ntt_forward, ntt_inverse, ZETAS}; +use pykyber::kyber::params::{ + KYBER_1024_POLYCOMPRESSEDBYTES, KYBER_N, KYBER_POLYBYTES, KYBER_POLYCOMPRESSEDBYTES, KYBER_Q, + KYBER_SYMBYTES, +}; +use pykyber::kyber::poly::{ + poly_base_multiply, poly_compress, poly_compress_1024, poly_decompress, poly_decompress_1024, + poly_tobytes, poly_tomsg, Poly, +}; +use pykyber::kyber::polyvec::{ + polyvec_compress, polyvec_compress_1024, polyvec_decompress, polyvec_decompress_1024, Polyvec, + Polyvec1024, +}; +use pykyber::kyber::symmetric::{ + shake256_prf_expand, xof_absorb_bytes, xof_squeeze_blocks, XofState, XOF_BLOCKBYTES, +}; + +const Q: i64 = KYBER_Q as i64; +const R: i64 = 1 << 16; +/// The 256th root of unity of FIPS 203 section 2.5 (written zeta there). +const ZETA: i64 = 17; +/// 128^-1 mod q, the scaling in Algorithm 10 line 14. +const HALF_N_INV: i64 = 3303; +/// Coefficient pairs, i.e. the degree-one factors of the NTT domain. +const PAIRS: usize = KYBER_N / 2; +/// Digest sizes of SHA3-256 (H) and SHA3-512 (G). +const H_BYTES: usize = 32; +const G_BYTES: usize = 64; +/// Bytes of a message / ByteEncode_1 polynomial, and of ByteEncode_d. +const MSG_BYTES: usize = KYBER_SYMBYTES; +const fn encoded_bytes(d: usize) -> usize { + KYBER_N * d / u8::BITS as usize +} +/// Bytes of the PRF output for eta (Algorithm 8 takes 64 * eta bytes). +const fn cbd_bytes(eta: usize) -> usize { + KYBER_N / 4 * eta +} +/// Polynomials in the exhaustive compression tests: 26 are needed to hold every +/// residue and negative representative, rounded up to a multiple of 1, 2, 3 and +/// 4 so the tests can build vectors of any Kyber size from them. +const TEST_POLYS: usize = 36; +/// Random inputs per differential test. +const ROUNDS: usize = 50; +const CBD_ROUNDS: usize = 200; +/// The residues where the legacy d = 11 routine is off by one. +const LEGACY_DEVIATING: [i16; 2] = [1875, 3125]; + +fn pattern(len: usize) -> Vec { + (0..len).map(|i| (i % 251) as u8).collect() +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +/// Deterministic xorshift, enough to cover the input space without a new dependency. +struct Prng(u64); +impl Prng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + fn bytes(&mut self, n: usize) -> Vec { + (0..n).map(|_| (self.next() >> 24) as u8).collect() + } +} + +fn modq(x: i64) -> i64 { + x.rem_euclid(Q) +} + +fn pow_mod(mut b: i64, mut e: u32) -> i64 { + let mut r = 1; + b = modq(b); + while e > 0 { + if e & 1 == 1 { + r = r * b % Q; + } + b = b * b % Q; + e >>= 1; + } + r +} + +fn bitrev7(i: usize) -> u32 { + (0..7).fold(0, |acc, b| acc | (((i >> b) & 1) << (6 - b)) as u32) +} + +/// zeta^BitRev7(i) mod q with zeta = 17 (FIPS 203 section 2.5, Appendix A). +fn zeta_pow(i: usize) -> i64 { + pow_mod(ZETA, bitrev7(i)) +} + +// ---- FIPS 202: H, G, J, PRF, XOF ------------------------------------------ + +#[test] +fn sha3_256_matches_hashlib() { + // H(s) = SHA3-256(s), FIPS 203 (4.4). Lengths straddle the 136-byte rate. + let cases = [ + ( + 0, + "a7ffc6f8bf1ed76651c14756a061d662f580ff4de43b49fa82d80a4b80f8434a", + ), + ( + 1, + "5d53469f20fef4f8eab52b88044ede69c77a6a68a60728609fc4a65ff531e7d0", + ), + ( + 135, + "fded8fd9d6551c601eeb3b7c6bc5e5cfd8aad1d015b7e9aaa9c9b9475231d5e2", + ), + ( + 136, + "cf3ccff92480a29160c2d38317c430e14749bfee1788106957dfe73f8c4930e5", + ), + ( + 137, + "ce9d7dc90913ee5d92745019479a5352c6d6279bef18ed07dc0a83ee8084daca", + ), + ( + 1600, + "ee74d621ed5fca2a7e0df941f27d648cbd61ef3fca28e5e41e2223f5bd524457", + ), + ]; + for (len, want) in cases { + let input = pattern(len); + let mut out = [0u8; H_BYTES]; + sha3_256(&mut out, &input, input.len()); + assert_eq!(hex(&out), want, "SHA3-256 len {len}"); + } +} + +#[test] +fn sha3_512_matches_hashlib() { + // G(c) = SHA3-512(c), FIPS 203 (4.5). Lengths straddle the 72-byte rate. + let cases = [ + (0, "a69f73cca23a9ac5c8b567dc185a756e97c982164fe25859e0d1dcc1475c80a615b2123af1f5f94c11e3e9402c3ac558f500199d95b6d3e301758586281dcd26"), + (64, "cb29601efbee71f4dfbb7f1c2bdaeafdb212df6ae35f8bb1ee6c0a245b99f3f35a82957567a30cfb01ae28b94c7223a62c5c786e8624b8faddcb913e3ab2ce71"), + (71, "3ccc850d53a1287af7b4560b2ef0d43eb5d9a80d62a0e9cf1dbc040135921104d4395168e90bfc871773ebb34bca1bd67056e1cc7dc7a48ff7c3167d389f117c"), + (72, "5d63f2bbe971a983ac6847480106e4e1264ee3a0befd79954914e1d86e795b2e18238f12fc5e46cb9cc78efdec610a93647cc04e1c23d8caaa6a58c21dd26c07"), + (73, "921d9b7b2b0f3066a1646dbb058c979cb3925dec0f8c269faaa7f9648e73465ae55ec527257d5d5e1cfdbf5d6799bea1004b6186f5108c74e3b92fe924166558"), + (1600, "50341eb0b689264de10ddaefb0fb2dd7612c8cb4f53de0cd87652595670760f915331c2f3925dc2f039868c9462f871112ed463f64cfa1d9ec00581e7bd08cf6"), + ]; + for (len, want) in cases { + let input = pattern(len); + let mut out = [0u8; G_BYTES]; + sha3_512(&mut out, &input, input.len()); + assert_eq!(hex(&out), want, "SHA3-512 len {len}"); + } +} + +#[test] +fn shake256_matches_hashlib() { + // J(s) = SHAKE256(s, 256 bits) with s = z || c up to 32 + 1568 bytes, and + // PRF_eta outputs of 64 * eta bytes (128 or 192, i.e. more than one block). + // The expected value is SHA3-256 of the full SHAKE256 output. + let cases = [ + ( + 0, + 32, + "46b14d3a928b31be5d9f6061fd205dd3d18098988afe3f1d5582236c4a55e937", + ), + ( + 33, + 192, + "d538d308117d3f95872dc11773170ccfd47e51c4a2edc54638f6d1a8a52eddbb", + ), + ( + 135, + 136, + "17c08e19c1fb127796032dc1d21e52d53073e28f0f8cc7c654387ba3a48298fa", + ), + ( + 136, + 200, + "0d95a081b2c703ed77878a0fbc83b3f77338d51e61ccb36d0c7a4966c3a0a77e", + ), + ( + 1600, + 32, + "87af496f9906d39ce1056f3eb4432874c8d498e014aa428996caff25c5daa2cf", + ), + ( + 1600, + 300, + "d8d474b58aaae109f477d90b0f90395eff0ad605c7fda5601e57aa454e77d8e2", + ), + ]; + for (len, outlen, want) in cases { + let input = pattern(len); + let mut out = vec![0u8; outlen]; + shake256(&mut out, outlen, &input, input.len()); + let mut digest = [0u8; H_BYTES]; + sha3_256(&mut digest, &out, out.len()); + assert_eq!(hex(&digest), want, "SHAKE256 len {len} out {outlen}"); + } +} + +#[test] +fn prf_is_shake256_of_seed_and_counter() { + // PRF_eta(s, b) = SHAKE256(s || b, 8 * 64 * eta), FIPS 203 (4.3). + let seed = pattern(32); + for (eta, nonce) in [(2usize, 0u8), (3, 1), (2, 7), (3, 255)] { + let mut got = vec![0u8; cbd_bytes(eta)]; + shake256_prf_expand(&mut got, cbd_bytes(eta), &seed, nonce); + let mut input = seed.clone(); + input.push(nonce); + let mut want = vec![0u8; cbd_bytes(eta)]; + shake256(&mut want, cbd_bytes(eta), &input, input.len()); + assert_eq!(got, want); + } +} + +#[test] +fn xof_is_shake128_of_seed_and_indices() { + // XOF = SHAKE128 over rho || j || i, FIPS 203 Algorithm 7 / section 4.1. + let seed = pattern(32); + let mut state = XofState::new(); + xof_absorb_bytes(&mut state, &seed, 1, 2); + const BLOCKS: usize = 3; + let mut buf = [0u8; BLOCKS * XOF_BLOCKBYTES]; + xof_squeeze_blocks(&mut buf, BLOCKS, &mut state); + let mut digest = [0u8; H_BYTES]; + sha3_256(&mut digest, &buf, buf.len()); + assert_eq!( + hex(&digest), + "9d1773faeaf6307f2b09a1cc695c318715b8f7e2c802f9418bbf07146c9294dd" + ); +} + +// ---- NTT (Algorithms 9, 10, 11, 12) ---------------------------------------- + +#[test] +fn zetas_are_montgomery_form_of_17_pow_bitrev7() { + // The legacy table holds zeta^BitRev7(i) * 2^16 mod q (centered); Appendix A + // lists zeta^BitRev7(i) mod q. + for (i, &z) in ZETAS.iter().enumerate().skip(1) { + assert_eq!(modq(z as i64), zeta_pow(i) * R % Q, "ZETAS[{i}]"); + } +} + +fn spec_ntt(f: &[i64]) -> Vec { + let mut f: Vec = f.to_vec(); + let mut i = 1; + let mut len = PAIRS; + while len >= 2 { + let mut start = 0; + while start < KYBER_N { + let zeta = zeta_pow(i); + i += 1; + for j in start..start + len { + let t = zeta * f[j + len] % Q; + f[j + len] = modq(f[j] - t); + f[j] = modq(f[j] + t); + } + start += 2 * len; + } + len /= 2; + } + f +} + +fn spec_ntt_inverse(f: &[i64]) -> Vec { + let mut f: Vec = f.to_vec(); + let mut i = 127; + let mut len = 2; + while len <= PAIRS { + let mut start = 0; + while start < KYBER_N { + let zeta = zeta_pow(i); + i -= 1; + for j in start..start + len { + let t = f[j]; + f[j] = modq(t + f[j + len]); + f[j + len] = zeta * modq(f[j + len] - t) % Q; + } + start += 2 * len; + } + len *= 2; + } + f.iter().map(|x| x * HALF_N_INV % Q).collect() +} + +fn spec_multiply_ntts(f: &[i64], g: &[i64]) -> Vec { + let mut h = vec![0i64; KYBER_N]; + for i in 0..PAIRS { + let gamma = zeta_pow_odd(i); + let (a0, a1, b0, b1) = (f[2 * i], f[2 * i + 1], g[2 * i], g[2 * i + 1]); + h[2 * i] = modq(a0 * b0 + a1 * b1 % Q * gamma); + h[2 * i + 1] = modq(a0 * b1 + a1 * b0); + } + h +} + +/// zeta^(2*BitRev7(i)+1) mod q, the gamma of Algorithm 11. +fn zeta_pow_odd(i: usize) -> i64 { + pow_mod(ZETA, 2 * bitrev7(i) + 1) +} + +fn random_residues(rng: &mut Prng) -> Vec { + (0..256).map(|_| (rng.next() % Q as u64) as i64).collect() +} + +#[test] +fn ntt_matches_algorithm_9() { + let mut rng = Prng(0x9e3779b97f4a7c15); + for _ in 0..ROUNDS { + let f = random_residues(&mut rng); + let mut c: Vec = f.iter().map(|&x| x as i16).collect(); + ntt_forward(&mut c); + let got: Vec = c.iter().map(|&x| modq(x as i64)).collect(); + assert_eq!(got, spec_ntt(&f)); + } +} + +#[test] +fn inverse_ntt_is_algorithm_10_times_montgomery_factor() { + // ntt_inverse is the reference "invntt_tomont": the 1441 scaling folds a + // factor 2^16 into the result, so it equals Algorithm 10 times R mod q. + let mut rng = Prng(0xdeadbeefcafef00d); + for _ in 0..ROUNDS { + let f = random_residues(&mut rng); + let mut c: Vec = f.iter().map(|&x| x as i16).collect(); + ntt_inverse(&mut c); + let got: Vec = c.iter().map(|&x| modq(x as i64)).collect(); + let want: Vec = spec_ntt_inverse(&f).iter().map(|x| x * R % Q).collect(); + assert_eq!(got, want); + } +} + +#[test] +fn base_multiply_is_algorithm_11_times_inverse_montgomery_factor() { + // Each product carries one factor 2^-16 (Montgomery reduction). + let r_inv = pow_mod(R, (Q - 2) as u32); + let mut rng = Prng(0x0123456789abcdef); + for _ in 0..ROUNDS { + let f = random_residues(&mut rng); + let g = random_residues(&mut rng); + let (mut a, mut b, mut out) = (Poly::new(), Poly::new(), Poly::new()); + for i in 0..KYBER_N { + a.coeffs[i] = f[i] as i16; + b.coeffs[i] = g[i] as i16; + } + poly_base_multiply(&mut out, &a, &b); + let got: Vec = out.coeffs.iter().map(|&x| modq(x as i64)).collect(); + let want: Vec = spec_multiply_ntts(&f, &g) + .iter() + .map(|x| x * r_inv % Q) + .collect(); + assert_eq!(got, want); + } +} + +// ---- Algorithm 8: SamplePolyCBD --------------------------------------------- + +fn spec_cbd(eta: usize, b: &[u8]) -> Vec { + assert_eq!(b.len(), 64 * eta); + let bit = |k: usize| ((b[k / 8] >> (k % 8)) & 1) as i64; + (0..256) + .map(|i| { + let x: i64 = (0..eta).map(|j| bit(2 * i * eta + j)).sum(); + let y: i64 = (0..eta).map(|j| bit(2 * i * eta + eta + j)).sum(); + modq(x - y) + }) + .collect() +} + +#[test] +fn cbd_matches_algorithm_8() { + let mut rng = Prng(0x5555aaaa12345678); + for _ in 0..CBD_ROUNDS { + let buf2 = rng.bytes(cbd_bytes(2)); + let mut p = Poly::new(); + cbd2(&mut p, &buf2); + let got: Vec = p.coeffs.iter().map(|&x| modq(x as i64)).collect(); + assert_eq!(got, spec_cbd(2, &buf2)); + + let buf3 = rng.bytes(cbd_bytes(3)); + cbd3(&mut p, &buf3); + let got: Vec = p.coeffs.iter().map(|&x| modq(x as i64)).collect(); + assert_eq!(got, spec_cbd(3, &buf3)); + } +} + +// ---- Compress_d, Decompress_d, ByteEncode_d (section 4.2.1) ------------------- + +fn spec_compress(d: u32, x: i64) -> i64 { + // round-half-up(2^d * x / q) mod 2^d, in exact integers. + ((2 * (x << d) + Q) / (2 * Q)) & ((1 << d) - 1) +} + +fn spec_decompress(d: u32, y: i64) -> i64 { + (2 * Q * y + (1 << d)) / (2 << d) +} + +/// Algorithm 5 for d < 12: little-endian bit packing of 256 d-bit values. +fn spec_byte_encode(d: u32, f: &[i64]) -> Vec { + let mut out = vec![0u8; encoded_bytes(d as usize)]; + for (i, &v) in f.iter().enumerate() { + for j in 0..d as usize { + let k = i * d as usize + j; + out[k / 8] |= (((v >> j) & 1) as u8) << (k % 8); + } + } + out +} + +/// Every residue in 0..q, then every negative representative -(q-1)..=-1, +/// padded with zeros to `TEST_POLYS` polynomials. +fn all_inputs() -> Vec { + let mut vals: Vec = (0..Q as i16).collect(); + vals.extend((1..Q as i16).map(|x| x - Q as i16)); + vals.resize(TEST_POLYS * KYBER_N, 0); + vals.chunks(KYBER_N) + .map(|c| { + let mut p = Poly::new(); + p.coeffs.copy_from_slice(c); + p + }) + .collect() +} + +fn residues(p: &Poly) -> Vec { + p.coeffs.iter().map(|&x| modq(x as i64)).collect() +} + +#[test] +fn compress_1_matches_spec() { + // Decrypt: ByteEncode_1(Compress_1(w)), FIPS 203 Algorithm 15 line 7. + for p in all_inputs() { + let mut msg = [0u8; MSG_BYTES]; + poly_tomsg(&mut msg, p.clone()); + let want: Vec = residues(&p).iter().map(|&x| spec_compress(1, x)).collect(); + assert_eq!(msg.to_vec(), spec_byte_encode(1, &want)); + } +} + +#[test] +fn compress_4_and_5_match_spec() { + for p in all_inputs() { + let enc = |d| { + let c: Vec = residues(&p).iter().map(|&x| spec_compress(d, x)).collect(); + spec_byte_encode(d, &c) + }; + let mut r4 = [0u8; KYBER_POLYCOMPRESSEDBYTES]; + poly_compress(&mut r4, p.clone()); + assert_eq!(r4.to_vec(), enc(4)); + let mut r5 = [0u8; KYBER_1024_POLYCOMPRESSEDBYTES]; + poly_compress_1024(&mut r5, p.clone()); + assert_eq!(r5.to_vec(), enc(5)); + } +} + +#[test] +fn compress_10_matches_spec() { + let enc = |p: &Poly| { + let c: Vec = residues(p).iter().map(|&x| spec_compress(10, x)).collect(); + spec_byte_encode(10, &c) + }; + for group in all_inputs().chunks(3) { + let mut v = Polyvec::new(); + v.vec.clone_from_slice(group); + let mut out = vec![0u8; 3 * encoded_bytes(10)]; + polyvec_compress(&mut out, v); + let want: Vec = group.iter().flat_map(enc).collect(); + assert_eq!(out, want); + } +} + +/// Characterization of a legacy deviation, not a requirement: Kyber-1024's +/// d = 11 compression uses the d = 4/5/10 multiplier (10321345 >> 35), which is +/// not exact for d = 11. It disagrees with FIPS 203 Compress_11 at exactly two +/// residues, 1875 and 3125 (each off by one). Legacy output must not change, +/// and ML-KEM-1024 must not share this routine: with 1024 coefficients per +/// ciphertext a hit is likely, and the bytes would differ from the standard. +#[test] +fn legacy_compress_11_deviates_from_spec_at_two_residues() { + let mut deviating = Vec::new(); + for x in 0..Q as i16 { + let mut v = Polyvec1024::new(); + for p in v.vec.iter_mut() { + p.coeffs = [x; KYBER_N]; + } + let mut out = vec![0u8; 4 * encoded_bytes(11)]; + polyvec_compress_1024(&mut out, v); + let c = vec![spec_compress(11, x as i64); KYBER_N]; + let want: Vec = (0..4).flat_map(|_| spec_byte_encode(11, &c)).collect(); + if out != want { + deviating.push(x); + } + } + assert_eq!(deviating, LEGACY_DEVIATING.to_vec()); +} + +/// `TEST_POLYS` polynomials whose coefficients run through 0..2^d in order, wrapping. +fn all_d_bit_values(d: u32) -> Vec> { + (0..TEST_POLYS) + .map(|n| { + (0..KYBER_N) + .map(|i| ((n * KYBER_N + i) as i64) % (1 << d)) + .collect() + }) + .collect() +} + +#[test] +fn decompress_matches_spec() { + for ys in all_d_bit_values(4) { + let mut p = Poly::new(); + poly_decompress(&mut p, &spec_byte_encode(4, &ys)); + let want: Vec = ys.iter().map(|&y| spec_decompress(4, y)).collect(); + assert_eq!(p.coeffs.iter().map(|&x| x as i64).collect::>(), want); + } + for ys in all_d_bit_values(5) { + let mut p = Poly::new(); + poly_decompress_1024(&mut p, &spec_byte_encode(5, &ys)); + let want: Vec = ys.iter().map(|&y| spec_decompress(5, y)).collect(); + assert_eq!(p.coeffs.iter().map(|&x| x as i64).collect::>(), want); + } + let d10 = all_d_bit_values(10); + for group in d10.chunks(3) { + let bytes: Vec = group + .iter() + .flat_map(|ys| spec_byte_encode(10, ys)) + .collect(); + let mut v = Polyvec::new(); + polyvec_decompress(&mut v, &bytes); + for (p, ys) in v.vec.iter().zip(group) { + let want: Vec = ys.iter().map(|&y| spec_decompress(10, y)).collect(); + assert_eq!(p.coeffs.iter().map(|&x| x as i64).collect::>(), want); + } + } + let d11 = all_d_bit_values(11); + for group in d11.chunks(4) { + let bytes: Vec = group + .iter() + .flat_map(|ys| spec_byte_encode(11, ys)) + .collect(); + let mut v = Polyvec1024::new(); + polyvec_decompress_1024(&mut v, &bytes); + for (p, ys) in v.vec.iter().zip(group) { + let want: Vec = ys.iter().map(|&y| spec_decompress(11, y)).collect(); + assert_eq!(p.coeffs.iter().map(|&x| x as i64).collect::>(), want); + } + } +} + +#[test] +fn byte_encode_12_matches_spec() { + // Algorithm 5 with d = 12. poly_tobytes accepts the signed range (-q, q). + for p in all_inputs() { + let mut out = [0u8; KYBER_POLYBYTES]; + poly_tobytes(&mut out, p.clone()); + let mut want = vec![0u8; KYBER_POLYBYTES]; + for (i, v) in residues(&p).iter().enumerate() { + for j in 0..12 { + let k = i * 12 + j; + want[k / 8] |= (((v >> j) & 1) as u8) << (k % 8); + } + } + assert_eq!(out.to_vec(), want); + } +} diff --git a/tests/unit_params.rs b/tests/unit_params.rs index ee39958..19b9130 100644 --- a/tests/unit_params.rs +++ b/tests/unit_params.rs @@ -24,15 +24,19 @@ fn test_kyber_768_params() { assert_eq!(KYBER_768_CIPHERTEXTBYTES, 3 * 320 + 128); } +// Kyber1024 compresses with (du, dv) = (11, 5) unlike Kyber512/768 (10, 4): +// Kyber round-3 spec (2021-08-04), Sec. 1.4, Table 1 (parameters) and Table 2 +// (sizes: sk 3168, pk 1568, ct 1568); same sizes in FIPS 203 final, Sec. 8, +// Tables 2-3. Ciphertext = k*du*n/8 + dv*n/8 = 4*352 + 160 = 1568 bytes. #[test] fn test_kyber_1024_params() { assert_eq!(KYBER_1024_K, 4); assert_eq!(KYBER_1024_POLYVECBYTES, 4 * 384); - assert_eq!(KYBER_1024_POLYVECCOMPRESSEDBYTES, 4 * 320); + assert_eq!(KYBER_1024_POLYVECCOMPRESSEDBYTES, 4 * 352); assert_eq!(KYBER_1024_INDCPA_PUBLICKEYBYTES, 4 * 384 + 32); assert_eq!(KYBER_1024_PUBLICKEYBYTES, 1568); assert_eq!(KYBER_1024_SECRETKEYBYTES, 4 * 384 + (4 * 384 + 32) + 2 * 32); - assert_eq!(KYBER_1024_CIPHERTEXTBYTES, 4 * 320 + 128); + assert_eq!(KYBER_1024_CIPHERTEXTBYTES, 4 * 352 + 160); } #[test] diff --git a/tests/vectors/gen_capture.rs b/tests/vectors/gen_capture.rs new file mode 100644 index 0000000..b694086 --- /dev/null +++ b/tests/vectors/gen_capture.rs @@ -0,0 +1,164 @@ +//! Golden-vector capture harness for legacy Kyber tag v1.0.5. +//! +//! This file is NOT part of the normal test build: the generator +//! `tests/vectors/generate.py` copies it into a git worktree of tag v1.0.5 as +//! `examples/gen_capture.rs` and runs it there with `cargo run --example`, +//! feeding one `parameter_set key_seed enc_seed` line per vector on stdin. +//! It prints one JSON object per line with the seeded key-generation, seeded +//! encapsulation and decapsulation outputs of pykyber v1.0.5. +//! +//! The passed RNG must never be consumed: v1.0.5's seeded keygen and seeded +//! encapsulation ignore it, so panicking on any rng use catches an accidental +//! capture of a non-seeded (OS-random) path instead of silently recording it. + +use pykyber::kyber::kem::{ + crypto_kem_dec, crypto_kem_dec_1024, crypto_kem_dec_512, crypto_kem_enc, crypto_kem_enc_1024, + crypto_kem_enc_512, crypto_kem_keypair, crypto_kem_keypair_1024, crypto_kem_keypair_512, +}; +use pykyber::kyber::params::{ + KYBER_1024_CIPHERTEXTBYTES, KYBER_1024_PUBLICKEYBYTES, KYBER_1024_SECRETKEYBYTES, + KYBER_512_CIPHERTEXTBYTES, KYBER_512_PUBLICKEYBYTES, KYBER_512_SECRETKEYBYTES, + KYBER_768_CIPHERTEXTBYTES, KYBER_768_PUBLICKEYBYTES, KYBER_768_SECRETKEYBYTES, KYBER_SSBYTES, +}; +use rand_core::{CryptoRng, RngCore}; +use std::io::{BufRead, Write}; + +struct PanicRng; +impl RngCore for PanicRng { + fn next_u32(&mut self) -> u32 { + panic!("rng consumed on a seeded path") + } + fn next_u64(&mut self) -> u64 { + panic!("rng consumed on a seeded path") + } + fn try_fill_bytes(&mut self, _dest: &mut [u8]) -> Result<(), rand_core::Error> { + panic!("rng consumed on a seeded path") + } + fn fill_bytes(&mut self, _dest: &mut [u8]) { + panic!("rng consumed on a seeded path") + } +} +impl CryptoRng for PanicRng {} + +fn hex(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push_str(&format!("{:02x}", b)); + } + s +} + +fn hex_decode(s: &str) -> Vec { + let bytes = s.as_bytes(); + assert_eq!(bytes.len() % 2, 0, "hex string must have even length"); + (0..bytes.len() / 2) + .map(|i| u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).expect("hex digit")) + .collect() +} + +fn print_vector( + set: &str, + key_seed: &[u8], + enc_seed: &[u8], + pk: &[u8], + sk: &[u8], + ct: &[u8], + ss_enc: &[u8], + ss_dec: &[u8], +) { + println!( + "{{\"parameter_set\":\"{}\",\"key_seed_hex\":\"{}\",\"enc_seed_hex\":\"{}\",\ +\"keypair_public_key_hex\":\"{}\",\"keypair_secret_key_hex\":\"{}\",\"ciphertext_hex\":\"{}\",\ +\"shared_secret_encaps_hex\":\"{}\",\"shared_secret_decaps_hex\":\"{}\"}}", + set, + hex(key_seed), + hex(enc_seed), + hex(pk), + hex(sk), + hex(ct), + hex(ss_enc), + hex(ss_dec) + ); +} + +fn capture_512(key_seed: &[u8], enc_seed: &[u8]) { + let mut pk = vec![0u8; KYBER_512_PUBLICKEYBYTES]; + let mut sk = vec![0u8; KYBER_512_SECRETKEYBYTES]; + crypto_kem_keypair_512( + &mut pk, + &mut sk, + &mut PanicRng, + Some((&key_seed[..32], &key_seed[32..])), + ) + .expect("seeded keygen"); + let mut ct = vec![0u8; KYBER_512_CIPHERTEXTBYTES]; + let mut ss_enc = vec![0u8; KYBER_SSBYTES]; + crypto_kem_enc_512(&mut ct, &mut ss_enc, &pk, &mut PanicRng, Some(enc_seed)) + .expect("seeded encapsulation"); + let mut ss_dec = vec![0u8; KYBER_SSBYTES]; + crypto_kem_dec_512(&mut ss_dec, &ct, &sk).expect("decapsulation"); + print_vector("512", key_seed, enc_seed, &pk, &sk, &ct, &ss_enc, &ss_dec); +} + +fn capture_768(key_seed: &[u8], enc_seed: &[u8]) { + let mut pk = vec![0u8; KYBER_768_PUBLICKEYBYTES]; + let mut sk = vec![0u8; KYBER_768_SECRETKEYBYTES]; + crypto_kem_keypair( + &mut pk, + &mut sk, + &mut PanicRng, + Some((&key_seed[..32], &key_seed[32..])), + ) + .expect("seeded keygen"); + let mut ct = vec![0u8; KYBER_768_CIPHERTEXTBYTES]; + let mut ss_enc = vec![0u8; KYBER_SSBYTES]; + crypto_kem_enc(&mut ct, &mut ss_enc, &pk, &mut PanicRng, Some(enc_seed)) + .expect("seeded encapsulation"); + let mut ss_dec = vec![0u8; KYBER_SSBYTES]; + crypto_kem_dec(&mut ss_dec, &ct, &sk).expect("decapsulation"); + print_vector("768", key_seed, enc_seed, &pk, &sk, &ct, &ss_enc, &ss_dec); +} + +fn capture_1024(key_seed: &[u8], enc_seed: &[u8]) { + let mut pk = vec![0u8; KYBER_1024_PUBLICKEYBYTES]; + let mut sk = vec![0u8; KYBER_1024_SECRETKEYBYTES]; + crypto_kem_keypair_1024( + &mut pk, + &mut sk, + &mut PanicRng, + Some((&key_seed[..32], &key_seed[32..])), + ) + .expect("seeded keygen"); + let mut ct = vec![0u8; KYBER_1024_CIPHERTEXTBYTES]; + let mut ss_enc = vec![0u8; KYBER_SSBYTES]; + crypto_kem_enc_1024(&mut ct, &mut ss_enc, &pk, &mut PanicRng, Some(enc_seed)) + .expect("seeded encapsulation"); + let mut ss_dec = vec![0u8; KYBER_SSBYTES]; + crypto_kem_dec_1024(&mut ss_dec, &ct, &sk).expect("decapsulation"); + print_vector("1024", key_seed, enc_seed, &pk, &sk, &ct, &ss_enc, &ss_dec); +} + +fn main() { + let stdin = std::io::stdin(); + for line in stdin.lock().lines() { + let line = line.expect("read stdin"); + if line.trim().is_empty() { + continue; + } + let mut fields = line.split_whitespace(); + let set = fields.next().expect("parameter set"); + let key_seed_hex = fields.next().expect("key seed"); + let enc_seed_hex = fields.next().expect("encapsulation seed"); + let key_seed = hex_decode(key_seed_hex); + let enc_seed = hex_decode(enc_seed_hex); + assert_eq!(key_seed.len(), 64, "key seed must be 64 bytes (d || z)"); + assert_eq!(enc_seed.len(), 32, "encapsulation seed must be 32 bytes"); + match set { + "512" => capture_512(&key_seed, &enc_seed), + "768" => capture_768(&key_seed, &enc_seed), + "1024" => capture_1024(&key_seed, &enc_seed), + other => panic!("unknown parameter set {}", other), + } + } + let _ = std::io::stdout().flush(); +} diff --git a/tests/vectors/generate.py b/tests/vectors/generate.py new file mode 100644 index 0000000..3360400 --- /dev/null +++ b/tests/vectors/generate.py @@ -0,0 +1,305 @@ +#!/usr/bin/env python3 +"""Capture the legacy Kyber golden vectors from pykyber v1.0.5. + +The output file ``tests/vectors/legacy-kyber-v1.0.5.json`` is APPEND-ONLY: +it is the byte-frozen record of what pykyber 1.0.5 produced, and no test is +ever allowed to be fixed by regenerating or editing it. This script is the +only supported way to (re)build it, and it only ever reads from v1.0.5 +artifacts, never from the current working tree: + +1. Download the pinned pykyber==1.0.5 wheel from PyPI, verify its SHA-256 + against the pin below, and install it into a throwaway venv. +2. Create a git worktree of tag v1.0.5, copy ``gen_capture.rs`` (sibling of + this file) into its ``examples/`` directory, and run it there with + ``cargo run --release --example gen_capture``. +3. Ask the v1.0.5 Python layer for seeded keypairs and decapsulation through + ``KyberK(seed=...)`` / ``KyberK.decapsulate``. +4. Assert that every 1.0.5 layer agrees (Python seeded keygen == Rust seeded + keygen; Python decaps == Rust decaps == Rust seeded-encaps shared secret), + then write the merged vectors. + +Usage:: + + python3 tests/vectors/generate.py [--force] [--keep-worktree] + +The default output path is refused if it already exists, unless ``--force`` +is passed; rerunning with ``--force`` is a deliberate re-capture from the +v1.0.5 artifacts, never a way to make a failing test pass. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import platform +import shutil +import subprocess +import sys +import tempfile +from datetime import date +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +HERE = Path(__file__).resolve().parent + +PARAM_SETS = ("512", "768", "1024") + +# Seed inputs: fixed so the capture is reproducible from the artifacts alone. +# d || z (64 bytes) seeds keygen; enc_seed (32 bytes) seeds encapsulation. +KEY_SEEDS = [bytes(range(64)), bytes(64), bytes(range(64, 128))] +ENC_SEEDS = [bytes(range(32)), bytes(32), bytes(range(32, 64))] + +# Capture basis, pinned so anyone can re-derive the exact vectors. +PYPI_WHEEL = "pykyber-1.0.5-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl" +PYPI_WHEEL_SHA256 = "0bd5d644e7783f4cf3bfffaa04fa42f96f43a68b6954e5927bc8bd94a2f2592a" +PYPI_VERSION = "pykyber==1.0.5" +RUST_TAG = "v1.0.5" + +# (public_key, secret_key, ciphertext, shared_secret) byte lengths, checked +# against every capture before it is written out; a mismatch means the +# artifacts are not what we think they are and the capture fails loudly. +EXPECTED_SIZES = {k: tuple(v) for k, v in json.loads(__SIZES_JSON__).items()} + +DEFAULT_OUT = REPO_ROOT / "tests" / "vectors" / "legacy-kyber-v1.0.5.json" + +PY_CAPTURE = r"""import json, sys +import pykyber + +CLASSES = {"512": pykyber.Kyber512, "768": pykyber.Kyber768, "1024": pykyber.Kyber1024} +SIZES = {k: tuple(v) for k, v in json.loads(__SIZES_JSON__).items()} + +def fail(msg): + sys.stderr.write("capture mismatch: %s\n" % msg) + sys.exit(2) + +def vectors_from_stdin(): + for line in sys.stdin: + line = line.strip() + if line: + yield json.loads(line) + +for rust in vectors_from_stdin(): + ps = rust["parameter_set"] + seed = bytes.fromhex(rust["key_seed_hex"]) + ct = bytes.fromhex(rust["ciphertext_hex"]) + sk = bytes.fromhex(rust["keypair_secret_key_hex"]) + pk = bytes.fromhex(rust["keypair_public_key_hex"]) + sizes = SIZES[ps] + + k = CLASSES[ps](seed=seed) + py_pk, py_sk = k.public_key, k.secret_key + if (len(py_pk), len(py_sk)) != sizes[:2]: + fail("unexpected key lengths for %s" % ps) + if py_pk != pk or py_sk != sk: + fail("python seeded keygen != rust seeded keygen for %s" % ps) + + py_ss = CLASSES[ps].decapsulate(ct, sk) + if len(py_ss) != sizes[3]: + fail("unexpected shared secret length for %s" % ps) + if py_ss != bytes.fromhex(rust["shared_secret_encaps_hex"]): + fail("python decaps != rust seeded encaps shared secret for %s" % ps) + if bytes.fromhex(rust["shared_secret_decaps_hex"]) != py_ss: + fail("python decaps != rust decaps for %s" % ps) + + print(json.dumps({ + "parameter_set": ps, + "key_seed_hex": rust["key_seed_hex"], + "enc_seed_hex": rust["enc_seed_hex"], + "keypair_public_key_hex": py_pk.hex(), + "keypair_secret_key_hex": py_sk.hex(), + "ciphertext_hex": ct.hex(), + "shared_secret_encaps_hex": rust["shared_secret_encaps_hex"], + "shared_secret_decaps_hex": py_ss.hex(), + }), flush=True) +""" + + +def run(cmd, **kwargs): + kwargs.setdefault("check", True) + return subprocess.run(cmd, **kwargs) + + +def git(args, cwd=None): + result = subprocess.run(["git"] + args, cwd=cwd or REPO_ROOT, check=True, + capture_output=True, text=True) + return result.stdout.strip() + + +def prepare_worktree(path: Path) -> str: + """Create (or reuse) a worktree pinned to tag v1.0.5; return its commit.""" + tag_commit = git(["rev-parse", RUST_TAG + "^{commit}"]) + if path.exists(): + head = git(["rev-parse", "HEAD"], cwd=path) + if head != tag_commit: + raise SystemExit( + f"worktree {path} exists but is at {head}, expected {tag_commit} " + f"({RUST_TAG}). Move it away or pass a different --worktree." + ) + else: + path.parent.mkdir(parents=True, exist_ok=True) + run(["git", "worktree", "add", str(path), RUST_TAG]) + return tag_commit + + +def fetch_and_install_wheel(tmp: Path) -> Path: + """Download the pinned wheel, verify its hash, install it into a venv.""" + wheel_dir = tmp / "wheel" + wheel_dir.mkdir() + run([sys.executable, "-m", "pip", "download", PYPI_VERSION, "--no-deps", + "--only-binary=pykyber", "-d", str(wheel_dir), "-q"]) + wheel = wheel_dir / PYPI_WHEEL + if not wheel.exists(): + raise SystemExit( + f"pip did not fetch the pinned wheel {PYPI_WHEEL}; " + f"got {sorted(p.name for p in wheel_dir.iterdir())}" + ) + digest = hashlib.sha256(wheel.read_bytes()).hexdigest() + if digest != PYPI_WHEEL_SHA256: + raise SystemExit( + f"wheel sha256 mismatch: got {digest}, expected {PYPI_WHEEL_SHA256}" + ) + + venv = tmp / "venv" + run([sys.executable, "-m", "venv", str(venv)]) + run([str(venv / "bin" / "pip"), "install", "--no-index", "-q", str(wheel)]) + python = str(venv / "bin" / "python") + run([python, "-c", "import pykyber"], stdout=subprocess.DEVNULL) + return venv + + +def run_rust_capture(worktree: Path, prompts) -> list[dict]: + """Copy gen_capture.rs into the worktree and run it there.""" + examples = worktree / "examples" + examples.mkdir(exist_ok=True) + shutil.copy(HERE / "gen_capture.rs", examples / "gen_capture.rs") + stdin = "\n".join(prompts) + "\n" + proc = run(["cargo", "run", "--quiet", "--release", "--example", "gen_capture"], + cwd=worktree, input=stdin, text=True, capture_output=True) + return [json.loads(line) for line in proc.stdout.splitlines() if line.strip()] + + +def run_python_capture(venv: Path, rust_vectors) -> list[dict]: + """Cross-check the Python side of v1.0.5 against the Rust-side capture.""" + capture_py = venv.parent / "py_capture.py" + script = PY_CAPTURE.replace("__SIZES_JSON__", repr(json.dumps(EXPECTED_SIZES))) + capture_py.write_text(script, encoding="ascii") + stdin = "".join(json.dumps(v) + "\n" for v in rust_vectors) + proc = run([str(venv / "bin" / "python"), str(capture_py)], input=stdin, + text=True, capture_output=True) + return [json.loads(line) for line in proc.stdout.splitlines() if line.strip()] + + +def build_meta(tag_commit: str) -> dict: + try: + rustc = run(["rustc", "--version"], stdout=subprocess.PIPE, text=True, + check=False).stdout.strip() + if not rustc: + rustc = "unknown" + except OSError: + rustc = "unknown" + return { + "description": ( + "Golden vectors of legacy Kyber (round-3) as produced by pykyber " + "v1.0.5. This file is append-only: never regenerate or edit it to " + "make a test pass. If current code disagrees with these bytes, the " + "current code is wrong. The only supported generator is " + "tests/vectors/generate.py, which reads solely from the pinned " + "PyPI wheel and the v1.0.5 git tag, never from current code." + ), + "append_only": True, + "generator": "tests/vectors/generate.py", + "captured_at": date.today().isoformat(), + "pypi_package": PYPI_VERSION, + "pypi_wheel": PYPI_WHEEL, + "pypi_wheel_sha256": PYPI_WHEEL_SHA256, + "rust_tag": RUST_TAG, + "rust_tag_commit": tag_commit, + "rust_build_profile": "release", + "rustc_version": rustc, + "python_version": platform.python_version(), + "path_families": { + "keypair": "crypto_kem_keypair(pk, sk, rng, Some((d, z))) at tag " + "v1.0.5; Python KyberK(seed=d||z) asserted equal", + "encapsulation": "crypto_kem_enc(ct, ss, pk, rng, Some(r_seed)) at " + "tag v1.0.5 (32-byte r seed); Python encapsulate " + "is randomized and not golden", + "decapsulation": "crypto_kem_dec(ss, ct, sk) at tag v1.0.5, " + "recorded via the Python KyberK.decapsulate() " + "wrapper and asserted equal to the Rust output", + }, + } + + +def default_worktree() -> Path: + """Worktree root under the platform temp dir; machine-neutral by design.""" + return Path(tempfile.gettempdir()) / "wp6" / "v105-wt" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--out", type=Path, default=DEFAULT_OUT) + parser.add_argument("--worktree", type=Path, default=None) + parser.add_argument("--keep-worktree", action="store_true", + help="keep the v1.0.5 worktree (default: remove it)") + parser.add_argument("--force", action="store_true", + help="overwrite an existing output file") + args = parser.parse_args() + if args.worktree is None: + args.worktree = default_worktree() + + if args.out.exists() and not args.force: + raise SystemExit( + f"{args.out} exists. This file is append-only; rerunning with " + "--force is a deliberate re-capture from the v1.0.5 artifacts." + ) + + tmp = Path(tempfile.mkdtemp(prefix="pykyber-golden-")) + try: + tag_commit = prepare_worktree(args.worktree) + venv = fetch_and_install_wheel(tmp) + + prompts = [] + for ps in PARAM_SETS: + for key_seed, enc_seed in zip(KEY_SEEDS, ENC_SEEDS): + prompts.append(f"{ps} {key_seed.hex()} {enc_seed.hex()}") + + rust_vectors = run_rust_capture(args.worktree, prompts) + if len(rust_vectors) != len(prompts): + raise SystemExit( + f"capture produced {len(rust_vectors)} vectors, expected {len(prompts)}" + ) + for v in rust_vectors: + sizes = EXPECTED_SIZES[v["parameter_set"]] + got = (len(bytes.fromhex(v["keypair_public_key_hex"])), + len(bytes.fromhex(v["keypair_secret_key_hex"])), + len(bytes.fromhex(v["ciphertext_hex"])), + len(bytes.fromhex(v["shared_secret_encaps_hex"]))) + if got != sizes: + raise SystemExit( + f"unexpected captured lengths for {v['parameter_set']}: " + f"{got} != {sizes}" + ) + + vectors = run_python_capture(venv, rust_vectors) + if len(vectors) != len(rust_vectors): + raise SystemExit( + f"python capture produced {len(vectors)} vectors, expected {len(rust_vectors)}" + ) + + document = {"meta": build_meta(tag_commit), "vectors": vectors} + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(json.dumps(document, indent=2) + "\n", encoding="ascii") + print(f"wrote {args.out} with {len(vectors)} vectors " + f"({len(KEY_SEEDS)} seeds x {len(PARAM_SETS)} parameter sets)") + finally: + shutil.rmtree(tmp, ignore_errors=True) + if not args.keep_worktree and args.worktree.exists(): + subprocess.run(["git", "worktree", "remove", "--force", str(args.worktree)], + cwd=REPO_ROOT, check=False) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/vectors/legacy-kyber-v1.0.5.json b/tests/vectors/legacy-kyber-v1.0.5.json new file mode 100644 index 0000000..822b459 --- /dev/null +++ b/tests/vectors/legacy-kyber-v1.0.5.json @@ -0,0 +1,113 @@ +{ + "meta": { + "description": "Golden vectors of legacy Kyber (round-3) as produced by pykyber v1.0.5. This file is append-only: never regenerate or edit it to make a test pass. If current code disagrees with these bytes, the current code is wrong. The only supported generator is tests/vectors/generate.py, which reads solely from the pinned PyPI wheel and the v1.0.5 git tag, never from current code.", + "append_only": true, + "generator": "tests/vectors/generate.py", + "captured_at": "2026-10-29", + "pypi_package": "pykyber==1.0.5", + "pypi_wheel": "pykyber-1.0.5-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", + "pypi_wheel_sha256": "0bd5d644e7783f4cf3bfffaa04fa42f96f43a68b6954e5927bc8bd94a2f2592a", + "rust_tag": "v1.0.5", + "rust_tag_commit": "e065e8daa095bed93cf6f27c46981b694b4285eb", + "rust_build_profile": "release", + "rustc_version": "rustc 1.95.0 (59807616e 2026-04-14)", + "python_version": "3.14.4", + "path_families": { + "keypair": "crypto_kem_keypair(pk, sk, rng, Some((d, z))) at tag v1.0.5; Python KyberK(seed=d||z) asserted equal", + "encapsulation": "crypto_kem_enc(ct, ss, pk, rng, Some(r_seed)) at tag v1.0.5 (32-byte r seed); Python encapsulate is randomized and not golden", + "decapsulation": "crypto_kem_dec(ss, ct, sk) at tag v1.0.5, recorded via the Python KyberK.decapsulate() wrapper and asserted equal to the Rust output" + } + }, + "vectors": [ + { + "parameter_set": "512", + "key_seed_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "enc_seed_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "keypair_public_key_hex": "ad9c21b9b7a12a80a93f8bc8db1400cec4765cc813a28876997a640cc1b14f064982911416a96bfa571327e786aad0b3c0c0262b758ef717c4d6d60338ccbece86439689162e5921988c076b016e6e7217559333b8e18091a720f0d44f76b0b1da5980af594078614696729dfab1c2699051a043a3aceaa5fecbbfd96cc04ab9b1bb98a192a537d7a456032955411c86c9bc8da2ab4fe2db4306e7ae0d950731b79b6eb4288ea98aec2108246191e386bf4b7c671ff4be70eb4fed24556dc96b3ab0ca37604f2097273934833b4c65888c91f9a45ea061a9d87167aa4283c529bfd7d6a1b6e32351ec5746a26516eb07bdcabb4a0cade0b207b1d6158cea11c1845a4b5914f384950723b66954cca0ba37f6f28bc1b6cff4806143e6a639d761837b0affc2b398983227f28e6b684fca3c97ce197e5cd8857aea09da563b68048df9a8790430b99aea0de1e73c13192d86a0803d839bfb92a734b68c2f4797c1b985f6fa0e63da0797a07d9686536dfb6539977f0a222fa6eabeb477cb1a6ab357b347f735522abc2761689ec02576b4971c2bb50d03245675e4b8e4084173819d9e8343c7d38b7af670cc775f439322190c46d150860aea180c146d58fb915d0465fa09483d750726e0cead686349a275e4f010797957330b51974c6cf84559177369771ca5ded48200c04aac62332548037c79b9497a7c46bcc8ffdc91a00bae44abaea0818e666b074a6a3c2de7658294922980ac4d1b67962a106149c3b6503f4328bce87511ebf743b1d0bdea401c38e124e674c3a9ca1272164e1a3867a92b7b07fc0e220291edf1be63a45567e5b2b1dc45864b83865ca9ab30acc878447bf8b1b901c7a926a887db117300b76aa0534f31823716224f619c1aaa5d037a3c3d012f1d9686980821003c0d56b21ec7c9b4fbb13e18d96e21417164c73b2773baa46734238ac6012994bd6136a94b0f0316338613901502c5db360314d16777a631b606a07aa3cb296576d32cae35701ebaba708304056fea2ecc1510450706beb021b590aae6a98965536d7c3ab9406747ebb02bff22283b849bd7d4cfcbd3f6eeba676b21e0f2c47522292482fd830f330c1d84a794bb94728b2d93fe", + "keypair_secret_key_hex": "41e421b495cbc3a614d6445fd8f21aed027e28558144a0049c500c20cbb6d45214d4e191c307438ad78f1f2c8e08ac83b8e01434f928ff33b4c32680020c591ca02659b44399827a8896035bd03717dc7bb4dbb0a556923714a019c12215c6a75580151c51bcf1a3bcf3d25ae8f34477dc670bdc149fd817c29b86b4e4656f94784ac446908c4cdea71baed34c621a4f346b0821d37058010c74878ae2715f85e4242ddb664bb51a8be3a6f07225643c7ec129250883b5ddf0371cec1fc5f16d96e15118d121d0f48a861c0dfd609a90840005e0212cec2bcd80a4a3a6cf35e3a82829cc141006e6442274c691a2aabc92208f228b35a16c179e3b8451ca1a6ecac897104c001b93572977afa0c12bac47dcd541d73a0cfd4919714a258ec9a48b90b2d588beac20c11a3864cdcca9e454c41fb30812858001e10755fb01a2284ea72a20c97c7f4907787aa73255b92b366254e0c491496c9312ac088e4c46a9c158ac5a371186b156b32144672c46e95c13375f3a95b0d59b0e45452ec59478d6da9c79801780ca4efbe00731fca6316a08659774e7318dffe034dca265976311dbb8cf696ccd6ed61713f3576f5921752764ec3a0a7d47cb9cac1d4da81eb9d65d07648064e769d7247a9681728a61c788753e1d226025e3962a09a8bcaa47af94ae18d1ac893710ef143258589929227718c0aceb9126a1135925249c0ef85939a54631313c76b00c442b1210124c6d9b04663aa36e78cbcfc94275e0b0620a1fe0c23a382a55910c7fd23103287cb98472b393073399f9200f590939b23ccd0ac4460364bf3384985924a9a0304573833049789a143f71794232c55af6c82b28e2c7d53cbe3259566c13c96a806556983cd85095cc3297e4743064958fedc0005c790dffb92c2a38a4014bb795744f27285dbde4a34fb24849175d0ab43735f15001347ee17b64c6a13495a07a3dfccfb8e59bbca0c312b931c6968861f5b970b6810b1ba41c472afe935c98c1151f604f88369ae047246e9132266ac90656066f4a1c5af81534fc5fb86311e001345fb9954e391e24873ec3f63a5c57cfad9c21b9b7a12a80a93f8bc8db1400cec4765cc813a28876997a640cc1b14f064982911416a96bfa571327e786aad0b3c0c0262b758ef717c4d6d60338ccbece86439689162e5921988c076b016e6e7217559333b8e18091a720f0d44f76b0b1da5980af594078614696729dfab1c2699051a043a3aceaa5fecbbfd96cc04ab9b1bb98a192a537d7a456032955411c86c9bc8da2ab4fe2db4306e7ae0d950731b79b6eb4288ea98aec2108246191e386bf4b7c671ff4be70eb4fed24556dc96b3ab0ca37604f2097273934833b4c65888c91f9a45ea061a9d87167aa4283c529bfd7d6a1b6e32351ec5746a26516eb07bdcabb4a0cade0b207b1d6158cea11c1845a4b5914f384950723b66954cca0ba37f6f28bc1b6cff4806143e6a639d761837b0affc2b398983227f28e6b684fca3c97ce197e5cd8857aea09da563b68048df9a8790430b99aea0de1e73c13192d86a0803d839bfb92a734b68c2f4797c1b985f6fa0e63da0797a07d9686536dfb6539977f0a222fa6eabeb477cb1a6ab357b347f735522abc2761689ec02576b4971c2bb50d03245675e4b8e4084173819d9e8343c7d38b7af670cc775f439322190c46d150860aea180c146d58fb915d0465fa09483d750726e0cead686349a275e4f010797957330b51974c6cf84559177369771ca5ded48200c04aac62332548037c79b9497a7c46bcc8ffdc91a00bae44abaea0818e666b074a6a3c2de7658294922980ac4d1b67962a106149c3b6503f4328bce87511ebf743b1d0bdea401c38e124e674c3a9ca1272164e1a3867a92b7b07fc0e220291edf1be63a45567e5b2b1dc45864b83865ca9ab30acc878447bf8b1b901c7a926a887db117300b76aa0534f31823716224f619c1aaa5d037a3c3d012f1d9686980821003c0d56b21ec7c9b4fbb13e18d96e21417164c73b2773baa46734238ac6012994bd6136a94b0f0316338613901502c5db360314d16777a631b606a07aa3cb296576d32cae35701ebaba708304056fea2ecc1510450706beb021b590aae6a98965536d7c3ab9406747ebb02bff22283b849bd7d4cfcbd3f6eeba676b21e0f2c47522292482fd830f330c1d84a794bb94728b2d93feb0448d37ecbcf2918a73bcc1b3d8174e322df9863ce92f5ea0f6bd6bca7da05e202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "ciphertext_hex": "0e00292e1fbc79a6f3adde16b3e2857c05fbf546d4417b2d8b4a49a89dfb66c45f2a63e83fecadde07dcb9ebd57193eabc4a320417503e12cd7d2d567a1d0aded5e5762dcfd5b437162e9060a0716d266fb009501b69ba1c06fd52385c52b2ece868dba62d4801820842cbd82202cdfb3d186bf16096a858824dadd36b241a09e6420181ba434649308b5f2cc6b961e69b68b19bf77eb4360f7a5439a535abb0dcf484048502084e3ec04f4454d6f107436a0455863ef29fb147e10be47d5dce047a6d020b68965458987ef2ec109946ccb12f9a14a9490a30176f404b0e8b9549093315ce57d1861895924d72bbd4bc4f073aa7f1ee5d15585e771450abf8c099d4d618099f95a3e5846b9b196bcdbc8f2d299ecceaafe6fc90316a8598e4c9d55fecff37ee288ba6c25017a93a5e252e8cb4f0f043b93b92276b3632443d38f8a781c78a5f7da5cf4e837fc127ea2bb14fa046b339ab4dd9d91e40cbbc112b189c9ca77fa29f01cb14887c2d9009618b15ccf9a6f5a9222054d730d7a04b02dd3bdbaeed8000d420dade007d4104a210b55b9a0262a9af2f946893e74c6f662d3b9338906723c85760faa3f3fcea0c1d3f1a24e530586d0575eba6f458a9c3c0fba7ddc45d8aff3908b1ce8a4faa36bc8611c924ba7d1294c8d381919734f55175035f117b8d60baff68d6ba70db6fa50d39651775cbe34e23eacb786411efeb575b5fe51884cc34e82e85e59a94d0d528a176c61296c62a0ab9613c6c8c1251f38e6653865cb949c450c151395eea77df89870cc015672c9b8909b604b6ef0f5b95aba7fe53b25fa768feaefe7439370ccacad0fe8f1b4afdb24cc0e09280238b43172c88745a0fa88bd4bc514733d9dfcab771ac6d15ad599773a208308d4ad246dcf56b7cfc8193d9992f85b1ee3ef126c63bbeb58db4b3a1a5f63d81d9ab803bacd5e35a570a0c5da173bbc9c9d82460d57a219639d267267f2bef779911c7f23fca6fca45fbae0ff81d57d4f3b1496a85d83c7b38d99515d3a67acd0358f8a92809d5aa5c873390ca81224c3d208ad2c89c5fb153db3e282909879199", + "shared_secret_encaps_hex": "e76255ef6053fc2233192873b89fe83f2fe5afd2709c0536d7269c2ed50e2eec", + "shared_secret_decaps_hex": "e76255ef6053fc2233192873b89fe83f2fe5afd2709c0536d7269c2ed50e2eec" + }, + { + "parameter_set": "512", + "key_seed_hex": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "enc_seed_hex": "0000000000000000000000000000000000000000000000000000000000000000", + "keypair_public_key_hex": "9ce5c67efc9c3367a83c7c42b0a12937c940cf711aa2a77f9438928d6950ea6007c0b902ff19777b20931266067700b969a02492ecc06673cef6e1ce36773a4bdb9538d534582618ded279e299a1202318138ca8d549b6c60881eed2b74ab884b93074685359603527ec638babb13d98f2a73e2a362a5662a351538d5357ec7c20b6674080c76a5365ab9f483f786c4e2cdbca23f0aa0a64589eb86eaa073771f04d39808e90443768aa0d0922b5dfa154e7e7093d34223814baa1b66dae3653cea215b3ac7d6c65b82c31aedfc03e3ef61dd29315b5c11c10a5b9df139217a0c23ef4bf81410ebf160f5bba4d3c6718b356b185655abed142a3c49905e2289dd4ca05a03a050ba008d352bd22baeb89784efc29010ca2784a6c21cb4693790099db9eca9325516995316415888c35a83461bb5a86b42517b2b156d28598c95b754e1b374f40a0ae53216972768867cd45a7cad6d5a7382b2a5d7c1694496e0548adf4571b4b5c5f5d21939fbc9bf269cdaf53be5f09be6193aeba591f4fc1b94397798db942e73a75590017be4b46c1e94dd0fb1b8a9476f28acf0b211ac1ca802ca8723e7a6f61f1343c17459e55b2732ccef63c80be4359f7f615f90c2e8c6c082385aeb71518892a1c87cb3f36668147d919c1066baa69b6e3f999f926780d661770152340731d0276c454a86c82e0b74c4b4c1b7737f2f9ae3222b7d0f2cbc001ba5421cc6a1a22d08587a258ce42bc69a1724476a5a796b97fa15c859c7c606ae32c81bc99396999d3457bb42395632ab6995340b514800f582e569534d01a2742dc2f0a35bfad618638b59bcfc33bb4c24d35e76562fc4bdc44b41641313b0c5fca926c54d3c642e52a62063867c8ba3a0a7976135efe3b6c49ea265d55a2d1d5677f49b09eba38234abe38b39527a81742144e934382b27564292a24d6a538916b2148d23f78e70a59e98096c91203f77965211f2eea2fc6fc1f59634e246b0c9e24769ce5a94fc27c33d55650028f951b2a3075c98b878746e03dc91cbfdd9725ec09b44db90f0c662ba5096e9dab076a7b7b1acab4eb2b795b6531ad56c35cab5063b9e7ea568314ec81c40ba577aae630de902004009e88f18da5", + "keypair_secret_key_hex": "32c188ba73c2080325db403bf0f7160102776c4711340a1ab4862f2a102fcd4aa139c7cff9f1bec4334401906e3884463e94230fdb1cd3b7772633613c49b20200a69e306034215854677015314ae9d5938ba9b8a781af3181089259543d85ac5037890d334301e775ec631d3b456f1407026e74be8e77407c99cbc658a35d3b17b13621f92662b960b40d1bc14a23488cf8a3e479c8376256eac40aed4543685a612b877ba672ba16bc3fd0a94d89e2c933f823a1a9699ff7b6979b6d2d338ec83293ea49965b025665991b4c07a96d19562bb392eabca844727c7fe957db97b08409b9ffb8cdce5744c7dcbec7e462ba96aef4b7a9c62aae5807bce5db5b1076943878405034191a4b5519a1c18ba2991aa8496416a5abf041dd784e27f99edd9bc46c464e68c81573e09b85e4576430b956516b048015663abb5b0808423490cc1a0d4b616d118aac32c7bc7781674e986c99a73e3deace4d151a1524ba6a65157c8268be2a0d1cf5ba009166b329192e9c8919533ec07c4e557a195ee755be479127856d9c4a143aa807b76a82a6f9a80f51bd5d96b91a2094e430c123061338d105a60176380a71056baac2cc12d908b795ac95ffcb7f1e48093f810afa103bb10a113b343a98eb66987009e40257bbc56b80e3c06b782a8ac4709966b89f116534493c990b88001009bdd57d0389a2d777a33f1cc3086c26c8d971e636933c6356dcf8bb0823025a9951c1f13a0b0a5967d1b404b49a2c7aa7fd0c4c31138a3c6b5349046e2f7a448c3500cf5cb35487367160a849f7995300839c721cd65baa2f7380b62c26b4c58f237a5149b9ba7d5952ce25696f04b9d0961d15881b4886c9e8c6a2ee2308310cbca703520853c7bd785a4c783ec2fbc6dc195f64165410f3366b636800c7cb5ee41d2fbc695b0275fa798c351667205a81055123af7a9427ec1109fb5f4e3b23c36a15f64045b2507b2418744e630831b7c0fbd4bf4d434f29d77f54d439723b3202b99a4ae2aa5b1aa792c12c570770b0651696ea3cbefb06c8348d5f25852ffb33b5d653c5295c999073a82466dac2307ac8179ce5c67efc9c3367a83c7c42b0a12937c940cf711aa2a77f9438928d6950ea6007c0b902ff19777b20931266067700b969a02492ecc06673cef6e1ce36773a4bdb9538d534582618ded279e299a1202318138ca8d549b6c60881eed2b74ab884b93074685359603527ec638babb13d98f2a73e2a362a5662a351538d5357ec7c20b6674080c76a5365ab9f483f786c4e2cdbca23f0aa0a64589eb86eaa073771f04d39808e90443768aa0d0922b5dfa154e7e7093d34223814baa1b66dae3653cea215b3ac7d6c65b82c31aedfc03e3ef61dd29315b5c11c10a5b9df139217a0c23ef4bf81410ebf160f5bba4d3c6718b356b185655abed142a3c49905e2289dd4ca05a03a050ba008d352bd22baeb89784efc29010ca2784a6c21cb4693790099db9eca9325516995316415888c35a83461bb5a86b42517b2b156d28598c95b754e1b374f40a0ae53216972768867cd45a7cad6d5a7382b2a5d7c1694496e0548adf4571b4b5c5f5d21939fbc9bf269cdaf53be5f09be6193aeba591f4fc1b94397798db942e73a75590017be4b46c1e94dd0fb1b8a9476f28acf0b211ac1ca802ca8723e7a6f61f1343c17459e55b2732ccef63c80be4359f7f615f90c2e8c6c082385aeb71518892a1c87cb3f36668147d919c1066baa69b6e3f999f926780d661770152340731d0276c454a86c82e0b74c4b4c1b7737f2f9ae3222b7d0f2cbc001ba5421cc6a1a22d08587a258ce42bc69a1724476a5a796b97fa15c859c7c606ae32c81bc99396999d3457bb42395632ab6995340b514800f582e569534d01a2742dc2f0a35bfad618638b59bcfc33bb4c24d35e76562fc4bdc44b41641313b0c5fca926c54d3c642e52a62063867c8ba3a0a7976135efe3b6c49ea265d55a2d1d5677f49b09eba38234abe38b39527a81742144e934382b27564292a24d6a538916b2148d23f78e70a59e98096c91203f77965211f2eea2fc6fc1f59634e246b0c9e24769ce5a94fc27c33d55650028f951b2a3075c98b878746e03dc91cbfdd9725ec09b44db90f0c662ba5096e9dab076a7b7b1acab4eb2b795b6531ad56c35cab5063b9e7ea568314ec81c40ba577aae630de902004009e88f18da5f5fa45abbfdcbb98dc4a17f6dae91b8c74851b10b2a2eb1e5156c02ecf56d9d70000000000000000000000000000000000000000000000000000000000000000", + "ciphertext_hex": "6bdb330ddb7b513c80dd8fccbd31877cd05cd866b49f7c113698c285aa2b949278d24d54fefd1190aaf8aabaf70b93a40274f9b3fff36dc83dc0f210b6a10a539b50ca3ccac3c894ab1dff31cc6d5f7fe1dfc08fb6f42b4ccc404e8104e4a64e7f04b8efd67fda5346b21545468c37cbcda44c7a11475ce95be9da618fa899d1f778e05535f161f47b4a9df94f6ade8d0401a9d678d5ada8a248336eb35ef37e935d4e2569cc4d89fac7d67144f7bc3183991c911428e13b827ff90fed5d4e6a68ade42f5941f9ab68e03cc2c712fcd226b3012d00ac96cdefe1e5f60981311d92ed825ff91953f3763b509594f56d47b803c8e3263b3e4fde8a1b1d5b61e5f9dd666a2439910f56b5f6e4b64f3430a6f5362c5655c6f71e16b145e9d7d60d98ba01b59d254b0c6e73e87c572b716a024d6e3298cc7fe1a4a5a61628d723027317f3676b172bc15cc8bb2171b72b722edf9b5a17e0ed8bcb18af24fa422f9d4cee273a27f9feaa3e13d703f399c095a63ecda25620c12df0a2281bf50c4a37ff87ae33f8a1aab8ec015a499e31e810bf479b1fa8850a9e6e13501412dc47668c61e81a4526bebc8d4bf3d260343a69c4c5be31fa0b7ab1687ee239428d24c5dd7118f7339fe18d78d2944a051026c30e4ef5562a702178cff8d857c80e9c8b90f978f3da1006214c980aa6bb209402a6922762572f9b70c0892456f7cc57c687e2a576083b254e3387772e01b99da56912f57730e501b8598f23c7c9f05f7c5dd4514bd710c6ec72a76db5e2265a158082a2923ceec38979c421ffd62b86d8c16ad77445b18b929d9d56bb83b876e6b87c109560484a9e73878b7cb5ad42f469f9c7b3323b01889332c66e84a195db7b0646245adc2680fa3565a76ff983d3a0b267038602bf12a1f7d42239f0b37c0bd5d4e0f80538e413d1c6f6484d69c52a87f4c735070c7cf710db188e851e90a36f0c6625fc79fd0925ffa9cecbb419bed79b016981345dc7fa33a41d1d375d177e528f2c8380f7b63e962cb415f6177dd7e833a0829f6fb181e5c1d8c5cf0e635dcaab9f61211dec71a5bf255ef953a9", + "shared_secret_encaps_hex": "fa719885de55735bf0325ecb15acec07351ed84884725e51344fcbca252f010d", + "shared_secret_decaps_hex": "fa719885de55735bf0325ecb15acec07351ed84884725e51344fcbca252f010d" + }, + { + "parameter_set": "512", + "key_seed_hex": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f", + "enc_seed_hex": "202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "keypair_public_key_hex": "fd0bbcce848362a9ad91f79278e88a3ba4b40cd9bdb4c8004b659312066c8fdc9119e680a2d2944275349f739fafb70fde2c4c882054d3c78ebaf3cf26bb9ca2eab7dfc8399b4774d220a7fe03074b91047a1332af9a8967d328636600e17abb7d7a71de24435137a7c3f575e9d1c20621590c934ca363a21f425247599547f2194d54b2b2da0e2e5b977fc10cdcc8b2e0b6b6473107467110ecb96c200722702245c20a8effe11c5afb39bf896183a03feff5bd2a44724f082767a11b37095715c83fea31b9eb378b68c1c377db81fdb89fadc740a3c68d3f4bb8d02b9831f53c16e71e67ac7f91e2973b61b8508b5afc8bad4629ba4779334aa43b9a80729d9051499b406127b0065315429996f9f095ecc65f486146bbfa3e1ee48d9fe2b483fab2004db3e1210dd298784f88b5e4553d1ce79730f26abb46a2fd1515024c880c599eb111008c240b91ea55897088609c15050a9c3c6a8baad98ba639905f911dc0762b3745a130c8457278bfe001c265261c00750caa597c7e37bfa1d87584cc2cedd1c2c1350058c47682074de8098024844fea5375127c7562b424f9eb7849e049772152fc7cae0d02a4484cc8605b55c62a453846570d6cbf19395a64362bc6877a01f72a43db556dc796a7a66043d856e4f78032a400227401d98c89bd8cacf15734577c34e1b6082938a6207735ded53816596be97821b3b89e15178ad2fb80f3046048707c0cc1838217b6eebc26c49c8211046c8b24b7a93c272ccb8a5269945ff784afccc0a23565f40c1173472737550c18d821bf794b013b111c1b5268cb55664c6ea6bb33901731dcc8a07bdb6073f6836164895c1096a529a0ba6126039704bf9004122c9f4df78d8ac14b2a62c27283901a24c421c04eab85077ec9b33fba65991b5228976fca722112091928042970656e9376c837232dd8180e80421e4c278129514675b86be4b0ac650c803fc74dcea02754fb42cab17b7cd54c2cc236e16641efc0c0a2ec5ab7b38c21da014df760fd0a6af59b5c5f1ccc920406c7dc20fc4b8d9e76c6020ab09c82b144212657f99ce478410916a7ccd5f35a8a831c4ced56d7fce5a78eb7e1883a14d4f2f0429cce8b0098b97b42", + "keypair_secret_key_hex": "ac75bc5b0b527df8cca5245091b0a557eb77dfa2233944aa845a61e036bd47aa76aba84d37501fbc76b16521524e07980437ce3f9aa45de28949aaa362549586f938fff8cfc3f7210553a0c68078c160c9cc29174b8760987b155a94553a89cad0b07084a00a66d100784b427a8853b12b2073773eecc86e02e3bf3bf55118b169d6d7babb61bacb2065deaa633530abb9c0b031357b17a17211e90833eb52fe2b723101405499952466749c1c314f7866d625b5f39252d37b0a3e86ca868cbf20cb7381a369a1c41cb7eb3e4456a6b62c9f55190bdc33c29620237da22201f10797ac4f77e1cefd465d17f36443d6c8bae70571100c441662d6c8318a1b9562843d73d578adeb60006d00077469cd656d516a78e238cdd4b9cccfe027d14bcd07c07432645547a95ecc595b0f5133bc260bbb6b01b24b81a496c42c8451c3f752762034003db181c9c9a51b3d551ab99e720683b2a3b4940d237738a7c8122d19a750c4ae35429add96a8030785f506a1aa9a84fb969c52102dfae7157ad1ad816aa6a3303ec59210ec1c7eae32000ccab664d0382b40123034b81eb20adaf10352e22f4a9c272a260c5f99279f0341603bcb7ee281fbd462488847dbf457495636787c3563721f0fa5777c4510149ab4f9e3b03f0bc2c43b51a7ea513ee6806bd01746ac433c8948f6353aad88943c41b6970283200b854178812989bcd47a24ea60212974ab4ad28236e378ea1c8de79669c527203a6240d34841942304c73736e3f92c58d32c80d553e76971e589786894a62b2a01fb3c4592e54e1e313ed57723d865205092b8a3d64b490ba0ab023fb50c3523122006006b5e270b262b9e937020dc85396a0293d771349becb9ad6143413a70419b9347877f0424320a0801be0102e4f65c67d91f9ef8380fd7037b374f6a727127d80383da488d99b741e6079c654129f67fd251c37a4c16ab33883aab1f7c3a49915bc0c6c33e14332a531b9eeb4679de5b42ef5a761b51c80cd09853ccaae3d6652cc53f34046beb996cd70a0d6d9acc4ab73b7bd510dfa52249b847393bc5dc2a6b76f070dd6998fd0bbcce848362a9ad91f79278e88a3ba4b40cd9bdb4c8004b659312066c8fdc9119e680a2d2944275349f739fafb70fde2c4c882054d3c78ebaf3cf26bb9ca2eab7dfc8399b4774d220a7fe03074b91047a1332af9a8967d328636600e17abb7d7a71de24435137a7c3f575e9d1c20621590c934ca363a21f425247599547f2194d54b2b2da0e2e5b977fc10cdcc8b2e0b6b6473107467110ecb96c200722702245c20a8effe11c5afb39bf896183a03feff5bd2a44724f082767a11b37095715c83fea31b9eb378b68c1c377db81fdb89fadc740a3c68d3f4bb8d02b9831f53c16e71e67ac7f91e2973b61b8508b5afc8bad4629ba4779334aa43b9a80729d9051499b406127b0065315429996f9f095ecc65f486146bbfa3e1ee48d9fe2b483fab2004db3e1210dd298784f88b5e4553d1ce79730f26abb46a2fd1515024c880c599eb111008c240b91ea55897088609c15050a9c3c6a8baad98ba639905f911dc0762b3745a130c8457278bfe001c265261c00750caa597c7e37bfa1d87584cc2cedd1c2c1350058c47682074de8098024844fea5375127c7562b424f9eb7849e049772152fc7cae0d02a4484cc8605b55c62a453846570d6cbf19395a64362bc6877a01f72a43db556dc796a7a66043d856e4f78032a400227401d98c89bd8cacf15734577c34e1b6082938a6207735ded53816596be97821b3b89e15178ad2fb80f3046048707c0cc1838217b6eebc26c49c8211046c8b24b7a93c272ccb8a5269945ff784afccc0a23565f40c1173472737550c18d821bf794b013b111c1b5268cb55664c6ea6bb33901731dcc8a07bdb6073f6836164895c1096a529a0ba6126039704bf9004122c9f4df78d8ac14b2a62c27283901a24c421c04eab85077ec9b33fba65991b5228976fca722112091928042970656e9376c837232dd8180e80421e4c278129514675b86be4b0ac650c803fc74dcea02754fb42cab17b7cd54c2cc236e16641efc0c0a2ec5ab7b38c21da014df760fd0a6af59b5c5f1ccc920406c7dc20fc4b8d9e76c6020ab09c82b144212657f99ce478410916a7ccd5f35a8a831c4ced56d7fce5a78eb7e1883a14d4f2f0429cce8b0098b97b4235215ba98dcca950fdd879dec12e6bfed8669bc8e2d0b79322f2cb36d8299ccd606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f", + "ciphertext_hex": "6583bf47cd15920bc9d98cba43efc2e828112c107798d5fab661926960117927d45a388815e78e00fdc8985fc07e5afb27193eceb08504f712309a3c27c601afac16720ef23f0c5572ac806f549331793942ce43b8717d2068b49d47efcbf42fccccf2a513391c3a6b01abf60c0505108a74128b3a8a5453721839a1234751ac2de0ed1537178f3c1ab3344304ee279fab00748a24a495f4297d6e0deb5f5726fe6d2fd901429d2142c69f83ce9f4e12f4572fde7015a6a66e6d89050b98d6c5f36759cd70ee1f41721a56ca754f1b4c592bb94adeb09b4c57bb26e74f9590fc5fdd38cae5d460aca3b90eccc60137d0d3c7669d0ebd3287dae24d5cdc538cce013366d9f0c69b29d8e991bef34603d5aa0521e20de937325eeca1ddf6319400bb181af0dcf07964f8c54f00e9e70874c282b911441ed73c42f4898190693b6acc14f3b83439324a0e53fc45a7928c6aa8d40cc10f81a6e4758948489dc5b9dc85ac277dbf08619bc15678777c936b4ed233a6de492597023d6e16910af80163796a5465dbcd334e2c970051aded806343968ac3c127846e838bd311c9975f8b5d3fb6875e4c79d7b4f8fa012853b1bc5cdf75dce087a52fc6d21e976017b745e73242867c3e6c0dadd21339805ef4a847a8364151825747b5cc0cd75d03e1400315e5861cc89624aa02a7e4ed859e62dc58dca856e0d0f0df24888e4aacc5be1a58071593c096cf00f2ff228cf4f9e0286ff9f351881370798bcc4b2521fc9c7a5eed673d47f10a3f9592165697bcb2c6d3ab83d84fef26574962649ee6da13054bf985b8a105efa76ef5d32e39d4649e52e79292a61eb80dabe7d08a3ab2e5481c6553bcb32767cc592829c24c779afd09217c339778682c52ebe2527fe0798abb2c93372dc6da64caac21f9a38ff36ccb5efe4b21cd5ee5e174fdbaec8c73b57a1d87ab153f34e42d687eb4725a686e96bef8e8269d846c1ed7c846a333761c82ae484051544ddae13a7d6f846d914d0e8865e013f4b09076592bf873b42ed3adedc68bb598958a8b0413a9e8fac5c51a86b514dc857724ad28ab2fbb03e1", + "shared_secret_encaps_hex": "6c9f66a0096ac9407a104b430e121371c9923d0547fa0cf6c00c3c14a127607d", + "shared_secret_decaps_hex": "6c9f66a0096ac9407a104b430e121371c9923d0547fa0cf6c00c3c14a127607d" + }, + { + "parameter_set": "768", + "key_seed_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "enc_seed_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "keypair_public_key_hex": "ec58a3b8081161b93ca506384104cfbc466b697ac0b451561878a4a174b394d24975000748233efdbc6856081371647bbaaab90e951b0e71adfe44710ef8312dd4250ae51fd39c467be520bef1c29ad0629aa29c9040185811bfafa36689bcb68b34adc9757413d8a416b811ef42c93a599a8e196a879aadc63227acb3c85871b26e7861d7e36a09e3068d3aa43356c86e6533ee7613a988c59c2517f927b4702a6fe1602cdf1476507cb005c77b60331af862992c596610179ed03248cd3c96bd617bbfdb7d928c92ca668a9ed39d49c348f5b7c6ea329be6782c3671a03b443899842f619218dbc0c945138cb5888bafa74f08d367037850ad62a2aa8b817eba2245f842d1319039d70abae132064702b1580cbe297873153689c75ac7f73b08f48e6bf8205581ca7444ac724b2fbfe07d65fb4826c8571c6b504db3c9e2e6996dcb88ee2680c4946b3121308fa974cad913a83536eb87486e84c57519c096542eb748b38d4b2cc3076d5174a73ff4a298e606c8478a776c55368622fbbc3f4743a372b1133dfc034a452ee8034c82341537526e1e7a5841b73ed304cdb8e9ae70a179fabb7c00e54bc7563a5ff75ecf2536bbd660c50384c13a626dd74dfe5c392698697fb5bd362c3248e54e656987392acedd5600a0da2ae253adfec12fb94b42950b707901b9cc94124a1ab220bc5f1ba10b9be946839a51f5f965afecae793c39f7c36eeea546b9676f99707d018c6e61a695e18ba32f53c948aa14ff7a7e13e3325d9cc49328141c8226a15c4b240b7f6c1a441a68aaf1a30a4d624572d3cd48518abb746adfc889c180830963653c973c12509822d54c365bb5e13c7a04b84d52ba10f8f1ad3530b3244b9a7763988d9c7eb810527e3493a8aa62bc88af17688732e244b6b61996f4afad08a8f6468cf42cc7e447b0bce41bbc428128301f8e8a08d32c164871b4ae637ae566b4362c81f25b4d8d096c80eb192e381d6ab4c15a73a2a7a49fec047ffd933a4fe4673b701fc5cb7c4f3ccc50b8a46e663116d3942190495f7b7c245c55f60a2e907b993e4b7856621b28da6a7cd229f027865d271495d279678ba02a07bf539373ca94ba3ff28416745ae8f727084a2babe3483510beee25922609a01d3a90a85640d875bf240a1ee1bc36e42133f85aa198010727c3690eec839a712f9921cece92801c6a5c91042f7793677674825945246d48992566552dd45778587c9d583d36e1192e8a5a6e39920b0187588b89dd671dfc3c8c694459fba659d7914f769ac4dcbc0709323269a90b2f29196993a51f771e99662a28603c2f4b5e735b8db2d74e05c633f277259e57ccb194ca5887c4c2ec139b60705f63939a12c3276692244a704a8a83e469717816cc33a13cebdbb5ea0ca7b06783aa06602b65346a0a481ae79726654e117c1bb6d0a16440235116563739b9076193be8a0de390cd0bdc1ba78a9b5099a6f2a89c47543a91f0437e293e65c3593eb2871fb77d6a381756d4916fc1a6edface6705c7f101bc5a731a5f081dc1109d7f845011d93d259c01870a11c9f00407443daf65b8bf703fb099864b4b1078861f7de36e3c581447b30f25568af9b0714b8ba5aaeb2acbd3f6eeba676b21e0f2c47522292482fd830f330c1d84a794bb94728b2d93fe", + "keypair_secret_key_hex": "e610484eea5623586dfa55cce4373817548647c41e899ca653d212e126484157248d90906ae33a7171486e20aa5f6c5b31b2620eb62154e22ee0884a8b80cfeea029cf8c42458aaad08010c1b074b03b7eec8ab1925c81c49aa1d9670c93779c22c406690b31bee2b30ca24357951ae687c167c2b055e4b891079896505fd5891f8495193e135305899eeeca2dc52c3ad2b7b8de1631a53384bfd49458025ac12bc749aa4ab76b7ee984446d6bb82af5cd873b8321d8672d1733160aac8d0956145539a74b9bb8f6075f15ab5913c5d60a67d9264303e341df0b5ca3d38d4e581f41818361104ffd536412509a123c8f39e9b5bc0a6c61272016404bd55bb85b16961e49c44c0ba83d38603b28aef9519faba882a5d7bcd03baae070072b050f16b123d1e985cd02528703a3acd4bd11bc56f8f96708518b194bb1a0b47818c9401f8a7435e5b349a15fccd0ccecc9372ae139c5b524494199791c45ba722b5f46626ad19e983987ce36879e141e0c2313a135a721b25c8ef0074e109f0e3ab3f51b91b08b17ad58c5dd2b60d40bb545c3684bbab83ae1510531b11bd0176dcaa1f080c5bd2019abd5341d77b808903d89305e17f81388a091f7e4b0ed711096a0b62bc59338f6a051ea49ea321c791739a400a487f9867af2304f1bc120509c30c51b4625b977e41804163690917d7c930222f1c5281b65e5553c1668aa953b3505043d391886540c6180ec9dfc324adcec507a7041e3c6180451af46f6083fb037da016b8c8464ce6a94db65aefa94642b89c46e62333c41cf09195048f30c57473e86a3ba129798866107d7d659b2b073e09c03e740ac9c668a7cb591ce2277a1968dd58a7ba30aab804c3c23f62489728f96e87dd61c93615195a6666cc990487a7a669fa545c3cc7acd490e49c44cea445fdba3324ba32d05b5bf55caa705999867d1ae11620a527b71f1ca93bee963b5f91ad75983852c208e942ad3347812e67d32b336e511243d30aff3d30885a270f84c89fc056460cc73c78c996ee1c7c39378df080066531159ac93cfa529bcd94b6f99affb845f5d5bcbb021953821bc9d34aed1c2b527c42d37ab3fccca160707954c89a8e512291686a492058abaf194d5fbb6721a9628584a5524c36c6624d9a708d1e0a90e217ad6a0a7bfe7305348aaec980b36d40f41047432d1c2e3410044724eeb55b90717b20563286ae88b002db1bcfb39dd82b1b0242c8ceaa1c3476c69cb1cad65369b37431ada0704bb957c81a00a427f8e909a04b216e9317100f86d06804d0d649d4e442562816239f556da19157980564582021443a38cb2c59b9b16c77603f1026e95c19793db679bea1a598563c2d959240364c8c55b0ab2932b0a789ec06408ecb88974ab51f4cf82dcc82b728c1ca2a294745fa8b95065f375c6353bd8010f4649847f445d9e6b85d9334f8f954f0c03bf1c1314aee78fb2a61dc4dc05b539abcb5abbefa775f123a9167341b19451f8299e066b97e4c691dd272aa5539c177128215b5c5ce697ead513da8992a1236b9d8b71f3651a516a1823587c7492b06ba67a7d545ecba51b58eccf29e915bbf159f7f01c68142f0acc774d9cca460212f1ea91ec58a3b8081161b93ca506384104cfbc466b697ac0b451561878a4a174b394d24975000748233efdbc6856081371647bbaaab90e951b0e71adfe44710ef8312dd4250ae51fd39c467be520bef1c29ad0629aa29c9040185811bfafa36689bcb68b34adc9757413d8a416b811ef42c93a599a8e196a879aadc63227acb3c85871b26e7861d7e36a09e3068d3aa43356c86e6533ee7613a988c59c2517f927b4702a6fe1602cdf1476507cb005c77b60331af862992c596610179ed03248cd3c96bd617bbfdb7d928c92ca668a9ed39d49c348f5b7c6ea329be6782c3671a03b443899842f619218dbc0c945138cb5888bafa74f08d367037850ad62a2aa8b817eba2245f842d1319039d70abae132064702b1580cbe297873153689c75ac7f73b08f48e6bf8205581ca7444ac724b2fbfe07d65fb4826c8571c6b504db3c9e2e6996dcb88ee2680c4946b3121308fa974cad913a83536eb87486e84c57519c096542eb748b38d4b2cc3076d5174a73ff4a298e606c8478a776c55368622fbbc3f4743a372b1133dfc034a452ee8034c82341537526e1e7a5841b73ed304cdb8e9ae70a179fabb7c00e54bc7563a5ff75ecf2536bbd660c50384c13a626dd74dfe5c392698697fb5bd362c3248e54e656987392acedd5600a0da2ae253adfec12fb94b42950b707901b9cc94124a1ab220bc5f1ba10b9be946839a51f5f965afecae793c39f7c36eeea546b9676f99707d018c6e61a695e18ba32f53c948aa14ff7a7e13e3325d9cc49328141c8226a15c4b240b7f6c1a441a68aaf1a30a4d624572d3cd48518abb746adfc889c180830963653c973c12509822d54c365bb5e13c7a04b84d52ba10f8f1ad3530b3244b9a7763988d9c7eb810527e3493a8aa62bc88af17688732e244b6b61996f4afad08a8f6468cf42cc7e447b0bce41bbc428128301f8e8a08d32c164871b4ae637ae566b4362c81f25b4d8d096c80eb192e381d6ab4c15a73a2a7a49fec047ffd933a4fe4673b701fc5cb7c4f3ccc50b8a46e663116d3942190495f7b7c245c55f60a2e907b993e4b7856621b28da6a7cd229f027865d271495d279678ba02a07bf539373ca94ba3ff28416745ae8f727084a2babe3483510beee25922609a01d3a90a85640d875bf240a1ee1bc36e42133f85aa198010727c3690eec839a712f9921cece92801c6a5c91042f7793677674825945246d48992566552dd45778587c9d583d36e1192e8a5a6e39920b0187588b89dd671dfc3c8c694459fba659d7914f769ac4dcbc0709323269a90b2f29196993a51f771e99662a28603c2f4b5e735b8db2d74e05c633f277259e57ccb194ca5887c4c2ec139b60705f63939a12c3276692244a704a8a83e469717816cc33a13cebdbb5ea0ca7b06783aa06602b65346a0a481ae79726654e117c1bb6d0a16440235116563739b9076193be8a0de390cd0bdc1ba78a9b5099a6f2a89c47543a91f0437e293e65c3593eb2871fb77d6a381756d4916fc1a6edface6705c7f101bc5a731a5f081dc1109d7f845011d93d259c01870a11c9f00407443daf65b8bf703fb099864b4b1078861f7de36e3c581447b30f25568af9b0714b8ba5aaeb2acbd3f6eeba676b21e0f2c47522292482fd830f330c1d84a794bb94728b2d93fe0df97a95f3e9fcaa29e04ffc0b246f17b50a19e74c90f88dadc41837dddcb6d1202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "ciphertext_hex": "1d2d3258f7b1fa3e9feca1bdb1b7be81cb2f8a755764f92c66577e5d5b7c85bb12f52b6f90daa17f19c996b339231f32466525389e5029e30d603cbf545e897a8fa4da5317a452d40be89811ec87243334e2d393b6dc4af940de5347ac732cc6347c10b949ed3ebc584584548fafd21d373bddfc497b9cfbc7cc8c6010cf5ade961e9def5b1df28dd9619e4273c53624f08e9d80799dc52a095079779b4c8d5ef1becb51462d312fb340d15f92a193f258844dfec937fbf293efdd7b44e39093aafda7b8d827e5f3bd91ad64688240a669e571cdf458f87a282ee342f693efc8b7b669fc0d05e4469b66715ba38dc500ed1e6a615c444636c8c98c8b004d7340e79a76c812ff9be87524a68810c99cad2164cfc683b0003eed88a4b47a572374a39af5deb44d4d64c592fed65819c883feb488f6538da5ab14c5f34414a5de783a04a427d251d669db890bccd2e37b9972ca537bd90c74e3c2bbaebe46e618de2d969ed566b8fff27e04dca248ec25146d3088aa8442d0c8565657271ecba57ca763be3eb969c8963f50ab3ec85b487a429e2fe9cbc68f0bfb9594acfd7ba3f673539caaad6aec89b103a4b03fd64c8669e0f3363e82a488e4fc4bffb9c2b3f4cb124e8b61e50adb986b2223f2d757f81614f29a0a2e2073708d3ec2c15a48ab962ea9c6870b826c974a6af952268e6d163051781abf0261276da4316d2f48ab05f40cb7963d9699389632ee7cd4204cec700608c914966e7720691dfde47f778997c082b84a681bff2b6221fb380cc3e9492984c6964ad01f0acd6413634ee396f7a90e1965498e36813f45c5ef90a2a136a401f11233ff5e8e290d925a68147fbc9eebb77ed11b58e757a2516e698bde9b2688174a37f4c18e38b2cea76d0c8769c0c0a018beda0ffef2876bfdc03671ac50a25bd34f60914da48cff24b09402de9c275c5c3802a758608b13ff82176eb713141bb6c1f36f7434a92063fcee60ceb3acddeacefbc8e7cf6f235b126c24b96b3ff3fb16441862458dce22c8da63c57269ab696ee0a2a6ed1a3681152d4fee8a2ae7b18a063194ea6460ac71cfaa9641d0a72379be214179b735bfd10c1019c5158307e6746b24940b548e57512d37b618795a633234bd975284dcc11d81d8586f46bc23107a91e67b36fbcb958234334489a00973c8ba5f2e6bb5933a319ded9c6bc046afb4c834e324aa886e4676645c87608dd5ac833f20666db76b0b948c0f12c407c637d72e0fa207ee53079ebaa5a2e86b6f15e158f78086ef0cb5bfff1bc51ed67602fbf8b4eb5bb58edcd5882f4d9bae93eb3e87313c5335e1ee92fa194a92acdc7c0fa596140c9dd33c68b662a284118c3dc1e3bb47362cd9cf9e02a2593823fd323d2a91735aecfc5b4a2aa013fddc22d94bea085d28f226baba08b2114586e0e70d78552a3d043b5c34d1f6472148838c495906b960d04e5d38731db7f693c796046d8a0bb738211134eab9d6a7b60a290ff6d8e023482d5b2f6f9ed99beef65104e6a59c235647", + "shared_secret_encaps_hex": "562e515e31bc90ea447434d29be8995aee241b3aaa6309b2a7cc4a79ead564e4", + "shared_secret_decaps_hex": "562e515e31bc90ea447434d29be8995aee241b3aaa6309b2a7cc4a79ead564e4" + }, + { + "parameter_set": "768", + "key_seed_hex": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "enc_seed_hex": "0000000000000000000000000000000000000000000000000000000000000000", + "keypair_public_key_hex": "9c3594e235a83146b490750afc8b5c659c880b5b5b64a837ef52a293f32e2d79a26e03b6f4466c149ba250bb6b1a1592b07188c96c9c466881830189e2f828537678e7c35efc680d32f65e7e4b651d5cb3a587ae30f39d62da3916586404aa3f1ea250c806111d5cc44ba6b5c636404c11c8cd8326888aa670a78da72053fe8727d2648844342b09a4cfec68a647805e67eb834b6b5c7e0065d0c38dba473fa33537dcb9583ea18746f9cb93f99f92356d8d68c1b8648185846ef8e97baf910ea2934b82c244de0600f5b0447648c36fd19ff86645d3e38e49338396069630e21fd3490fd1b92c2d9984a661c6b7415d4939ad9545c5ca1a5ac34504bf34ae16721a299a03f30953adf57a53546d48ca7b9e6120e9ba8e56ab479d77830d690052b9a19c1628e368275dcb417cd1b644d0652a25a2f2844bf785ab22d84221785879cb37a9f7ce999012e8493876222d2583b95f720dc2006dc559434f33a1b845822a11139a083518fb3468977cfaa79f5ef9684b3a40b7f2669b05014fe198edc734fac227e6d74786fb1778456201076de7a4665e7645d337c246450f0da35090a35bda445a40b693e8d6960f101c0be27d039a7a22371b5536804587bed92987f3ec61a32a60a93c0e0f831fdab12f012148b65b897f79b86c4990b30bb26c6424e7f790ebb5c0c5048c166656b0012b9fe8546a010bb333956a857bbcc03b1a3047bd86206b2226583b1d7b089845112957617ffa93665487b24691967ae6b641209e0eb2afb1148e1daa767f1763b5f9608ce506ba9398e746909d523947476f6dd835fb8a4b5e8c26bcd0b6b9c62fe46926558a84a1d254662c38cda50f5e27c3f09a567ae66f26ac2490717e6045162b154ea5919ffb0896aa2484b2b77c2f8c808bb998c6c07ef7a025285115f87b7ee4546ccf5229ac024b9b1a4f603a910d6a8f71bb3340f8801b3837d0f8c22b304e31b914f9ba5e48f141110867f5c6727fa246c6996b07c30be499ab089217695a7f5df75f955032a0d8ae082154fdac647c7b978cfc8a428c620f208d80586659728548b11327d997c853348f7b4870e04dd6007b7102047f3080332147ea09866c935e1796953b7c1e92daa8aa126e307a38af3c906bd6b4fa6c5e8d49ce085c26344394e5b68dec8350a0389fb4f2bfdd3773d225b497a2a83af32c8067563d750342d75114024a3d5b9e6c3b16e9c428774317b1788d54fbc5b439c1d9571cd7abb7bd284c98583215f03b143680a0d0afae266bef931cc1a2aa22f8c8d550a69a0a3435a7cbcdba5c18d631c45bc31255c40a643010e595186768d97947a0d1610f117023d8bb19f24ae12271f1690659025a6ed230080b61ce619b86601aaf5b467bb3128475599132aa162140c200bd3888a4567c5f69429b88d353978607d74bcff6399a0934130122b7fdb66083495064b77200c9111dfa2528288e692005daa75c48622cf4a59e73b495c5a28f5a3084fea022829b7299b836edfa0257c99db18892d2c05407f978f87603559595b2a12ed219b688e84ba870c1fb419739f555840a3cbf915657a524506446d4750522521b8167ad26191ecd6a5d4315c0ee940d60780779350aad56c35cab5063b9e7ea568314ec81c40ba577aae630de902004009e88f18da5", + "keypair_secret_key_hex": "891865aef10cb22387cae735180b85d14c436f931790804047f7363715b2c1c430d39667f765af885a7ca5aa79dc54cdfcbac3b7aa5b55868b79d69498f858a73476a605877a1449f58ccc25107367ab3e335202a59a7a40d7a24a107d099a1980c3164150b3b7c5339a745f3ab31bdd9c7d23a38592287bdd113864a41bd72845ca192189fca702545c28f7a7cc670f81c9be5584325074238380c469055b6e3c42e8288e93830a8b1c29ce858dd8a0a155a1504df4a82d893be8646c2321344638900d2916eea42f4c35ab16e77bca81a5e2f8086e5a5685e8a839c0960ac0574ca43d79fbc367534a71cc7e5ab40b8eec306b82c784c8b2cc48068cc165ad4cc039d934f6b222cab4971177687d2075d66abd8ba0ab7fdc5caeebb9aeba1b50c5c6ecf7c5ba6c242b444ae8375acde408fe61617bf87c7359ca8513a2b8a33b5447298b063fd8cb15cc0059cfd38679e53447100baed9a5e41986bff15aa11837214cb6b239bc74c190df138904d96f5f7305ff3cb0cbca9208f79163613eda4797ce8c5b2b257ab13c4aca24b367a6617136bee92ba57a22c459259095853acd53ad6e739efa51312ce685a180b522e4ba54f847129ba97c171979b008c802b0e1a33200e51081c18116a5213ed386d47c3414278957cc0e18d65b9ce866f34aacf800be7cfc7bee51314476881b49422f2c8cad276d24707b996ab4ffe6c50a4c2323e6563b602bd5631b5bbb4cb60311ec8564b715442e9470dc906c0e184e2cb66b5ebb4e9b9b5d726247f3a77b46844ab6b1ac88fb84a263c694407031e223cba49b51a624612518c2106c7ebbab910a14a9b120dbbbbb1dcb705735b37b828e17d15da3d47cd7a5c3e6d40504a4b65bbc10a5699d11d29eebfc862fdaaa5f71bec94170ed703717b40da37436f2cc5ecfd1905a824d09703092a0b602c7cf480986dcab5e038729a7b3213cdac265306685455fa38bca3550739bf68bac89989d087339e19c4b6446f8e96692396cab453ec1238ed8a796c9f234ed2a38a1e51dc0978dfa9c6099134b49197f4ec86b9863962e857f62c715b3898a2b996ca446772203a9c1e15bcb140d959a7cd60c9952742bd30872b22897e3037795da5ab2c2485f41ba8e2c2b0879509066ae4b509025fbaf9f92b5744729da57c40f195ca7bac0910c680de01ac5665d93b04404a5702f94431039a9cd7884a7ca74a76046a5430e6b4cc43964b40ce0260631572dc7c45cd58a2c9c6df8069d4d6c59ba418f76a9387e7712ecd65630f6c0ecb67893e06ddd68851c3c4939e3add0d2989b76b11032cb5aeb8d61195e55097bc82a4c26141869a5ceb8f3805468c1abd53bc9b198d9a528238a8e9f57811963ab56c40713396f09d917dce78f88bb639dca488526a4cb9894b6a50e1bc38476d895af8c0733b16d851a209453180fe318ba7371137688974c6a229179bdaba129f27ea999350f952e4eaa62e489cad6d969984842942338554a69f9e41659a6c87e5b284a8173e9700219ec298f812cb6e350b6d314a83017def1300ae57ada03af9f76534eb82d8f4abe9d10a23f2656aa73cab38497725c408ba0429aa85a374b449664288ebab79cb3cc9c3594e235a83146b490750afc8b5c659c880b5b5b64a837ef52a293f32e2d79a26e03b6f4466c149ba250bb6b1a1592b07188c96c9c466881830189e2f828537678e7c35efc680d32f65e7e4b651d5cb3a587ae30f39d62da3916586404aa3f1ea250c806111d5cc44ba6b5c636404c11c8cd8326888aa670a78da72053fe8727d2648844342b09a4cfec68a647805e67eb834b6b5c7e0065d0c38dba473fa33537dcb9583ea18746f9cb93f99f92356d8d68c1b8648185846ef8e97baf910ea2934b82c244de0600f5b0447648c36fd19ff86645d3e38e49338396069630e21fd3490fd1b92c2d9984a661c6b7415d4939ad9545c5ca1a5ac34504bf34ae16721a299a03f30953adf57a53546d48ca7b9e6120e9ba8e56ab479d77830d690052b9a19c1628e368275dcb417cd1b644d0652a25a2f2844bf785ab22d84221785879cb37a9f7ce999012e8493876222d2583b95f720dc2006dc559434f33a1b845822a11139a083518fb3468977cfaa79f5ef9684b3a40b7f2669b05014fe198edc734fac227e6d74786fb1778456201076de7a4665e7645d337c246450f0da35090a35bda445a40b693e8d6960f101c0be27d039a7a22371b5536804587bed92987f3ec61a32a60a93c0e0f831fdab12f012148b65b897f79b86c4990b30bb26c6424e7f790ebb5c0c5048c166656b0012b9fe8546a010bb333956a857bbcc03b1a3047bd86206b2226583b1d7b089845112957617ffa93665487b24691967ae6b641209e0eb2afb1148e1daa767f1763b5f9608ce506ba9398e746909d523947476f6dd835fb8a4b5e8c26bcd0b6b9c62fe46926558a84a1d254662c38cda50f5e27c3f09a567ae66f26ac2490717e6045162b154ea5919ffb0896aa2484b2b77c2f8c808bb998c6c07ef7a025285115f87b7ee4546ccf5229ac024b9b1a4f603a910d6a8f71bb3340f8801b3837d0f8c22b304e31b914f9ba5e48f141110867f5c6727fa246c6996b07c30be499ab089217695a7f5df75f955032a0d8ae082154fdac647c7b978cfc8a428c620f208d80586659728548b11327d997c853348f7b4870e04dd6007b7102047f3080332147ea09866c935e1796953b7c1e92daa8aa126e307a38af3c906bd6b4fa6c5e8d49ce085c26344394e5b68dec8350a0389fb4f2bfdd3773d225b497a2a83af32c8067563d750342d75114024a3d5b9e6c3b16e9c428774317b1788d54fbc5b439c1d9571cd7abb7bd284c98583215f03b143680a0d0afae266bef931cc1a2aa22f8c8d550a69a0a3435a7cbcdba5c18d631c45bc31255c40a643010e595186768d97947a0d1610f117023d8bb19f24ae12271f1690659025a6ed230080b61ce619b86601aaf5b467bb3128475599132aa162140c200bd3888a4567c5f69429b88d353978607d74bcff6399a0934130122b7fdb66083495064b77200c9111dfa2528288e692005daa75c48622cf4a59e73b495c5a28f5a3084fea022829b7299b836edfa0257c99db18892d2c05407f978f87603559595b2a12ed219b688e84ba870c1fb419739f555840a3cbf915657a524506446d4750522521b8167ad26191ecd6a5d4315c0ee940d60780779350aad56c35cab5063b9e7ea568314ec81c40ba577aae630de902004009e88f18da541b5e9a0e800819ea5b04df9adf3ecef916f8f44f6abf193fbc959572615908d0000000000000000000000000000000000000000000000000000000000000000", + "ciphertext_hex": "a90ff52c32784238db3dfddcd96ba71d21302c278aa8b8f5861e6795a495c7ee718874e229114ba8ece25f3758eccd90002337726e6c5d6ed78807f5166eee6a23ca7f47238fe53f858d0680fbef9635ce7bf054ff4b5bd906a9ca05356251247ef2c27e4da7051510cf5c3354d6c3e6d80d1adf33703c469114b228f1de4145ec1a5bde8ac8873957c8d8c59506b7bf0fee695c83894b6772062fc892482b5d8e89748216443017e91bc6dae94cf9a3f3a6338f3243224fb941c552c4d2c99a9fce69a34ab293b132482a14e18333754b2b1b77856f9c9394e36e3038034a91cd38661986640a86ec5331a3153d26c23269915aee265c43ab03ef0695d83359214d255a52949aff9baf182784908585df20b48baec7f201e990e1f0b03dd1b665201b1b2ce3f2a52016d018669066446fec19a74c5d56dcaa41289806f7267dbdab0915e8d8bc96c8ee30249eeb2b840b6ead08e3826cf8d1b4b27ccdca819245db100a88e533ea4453dea3b1777000efd302bb56d2d70c37924ea538cbea9082d84e24cf0393ecf4d3c777f059343b34c95085500a1ef5fe6327aed3905950a84a79efe8bc09e7f7bb6e35a3a2df4df16f1c0acef28af6532dfec2e8c5a13dd6e12561db3842ff74cb79fc86e537a35cdc367f9e1f594884e7d8aaaca2e18d73a471910c281b574ce5a1f691f8e7af9b17c75233831f368b156cc14679ceecce4f742f583503c733421ae4afd5b4e4916a392cbc9b2599f281bd0f49f5f25925b819d4630510e43d428d3adf100685255a0fc4e0bab9e95f6655f311712731f604589c33dcf349172d61e00542ae8a1e2be02043fe2672a498810d6cc676f8b12af5eed49a3d8795d419840ead46e750d0a014d91e1a6058b50c9b52166d2671d5de588526ff2d9029c091b339947f4cfd2b1c6dcc207dfc493a869f92fc5c4db64186aafedc3c4928e73a7996952f96915d9d277e5d7579c6c3a35587afc8b22c13b4ba994c7c4b60e1cea75ec3ff639d06f751bdabf4696f43aa56c1cee03da33e821619360757992e0dc728c574ab22beee3c8e8b601f60e076557a12d5110f0022a9c7f72fb23784bebde55af5fb264990aec2b3daedbcd37128eea0615cd8aa888c944764c4cb4e3af3759374779f9381d66a2608ea432b73867b479a60db1ccc7dd390867b85de6078b99cdb2e8fc57c4a988e6bc01f054d4376747ae5a7a221219bf045ccedc5255662c1b079f17b5cb3faf48d27d884feab4e91989ef2cbab3c2efcb5b7f034ee7beb6df1339ced3537e0042174c137eda5ea91a28711bcd813cc93c3c75df011e4007a324ea60cfb81fb9b9fccf7b8eb37e421e5fc6336aa0fd0d8c4fc0afce7a6daee69943583d76942e9062586a78b9ca6e222fae93820169d862b614daac66af26421aa7597054b42e8755f9b8f9600f3cdd88a427db572e4d0662069d421ec9f40d147ce77792a83f5a5e11ac5978c0d300b4542840af0f9b42345dc9ce515a63eee584ecf2b462f6145ccb5288da5e77b15", + "shared_secret_encaps_hex": "dd89c0fef4f70e0009057db5ee1b1e7502c821e5e75b8f6513bb127eed969c54", + "shared_secret_decaps_hex": "dd89c0fef4f70e0009057db5ee1b1e7502c821e5e75b8f6513bb127eed969c54" + }, + { + "parameter_set": "768", + "key_seed_hex": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f", + "enc_seed_hex": "202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "keypair_public_key_hex": "60636ec927984eb565dbf90b5096c8cef429e250502a9769dbc45bc2584cf6e1bf8ee2379ce645b23175361844b0f98877daa7c689ccda931c035681d7e25296d57d8b1c984ff921d5305e97f3c87d48089feb0a0f005271bcab1e37c697f5122a056aa024689ce9217bbc06e0bcaf0b1a8a2f627f64472a36e16399f28c58fc6284bc327cd6b670b7902d9b4aa8b73337232a6daa5e77d81d9a284e4a5508c7ea5cc5e3605c3aa086c7ae5ccb7d4b8b67e8b02d1f20afb08a57c68ba8ca41ba92f6a5b5e8a758b90b57236df9b92e4a4706d9c34037b9b9e9c3c34f52c02e9975f6788d2d4a5e7de242e55b51e1e2694a4208cbcc5877b434b884ccd2c95621c898dff69e85a7453f24ac4435731b0b9fa5f4219b5b57807cc32e441f7e0a3f1bb3a5bb4807fad742bab7c3c7076b9ad44105204ada911d515a6712964f0f497e05a00510075757a2508fb14c276bacb0b780d5a92da0107cc3b0c4d4aacd69d33b0538bc5c4ac8c3e26df1c27054d1170d4ac946b7659ef90c14945a0b43100e663f008d33874bb9b403a24f10173c4907e6e8060267ab75ea4e8f7078e8571db2026b7a16309f694fe7332e6de130bc4b8e1a73678a0482d2939bf3e92b53f620e5915b22b94882c99249d1758ad63cf2c53c46251b74577a22b86f765844aac97d2b3c9be2591f7484608c492a0abb41fb52c91363cf1ba4bd8156cee3740730c417ce2056575bb5eb355f4b976ea181026bc21f228561441894852102667ca82bb0bd0f82c0655a2f91133630db9f28b26023234503232902d904b86bb9f3f715f96a4f08b34664d3352ccab6d6eb5075c24a49dc91302174587111d3723c8dc8562ff96f2c177ef9f7a2dfe484c1373b367490fff80a13ec76f97b8c84c40cb7a6760b3974880181b3a11aee337b2503287a104e3d29480a958935ec48e460953d9a5c83e19312973e90e195376c8a28e87109263e78b433524874185772de69bda0e4a801a48c5a50bf5215cc8d50b88e2b4152fa9b059909a00a989f03196518c516093593fab2b1e27dedac706886ce7a28a9da01a90c5b0c560c5c229a18dbd39b63291b45899e064a71b26c0ee47759e467af03a05b6f047eb32314c92aa6300136fa856a77f60e27b9624b932991b96e8e5c070e87964508ca11fb77f9f44a7b84186c7b27d19c18dcfc4ffa28b113801ace104dd5d03f6dec9e6a50cc5cd68c37094cfcab3b73951093f010d7fa3435aa676d5219fc77bcbaf608a8f0030bd9a253eba7081a0081764988d3b5ed3c920ea39018e87ff23a08799b45c6b9b6b3c095e2a7c9df7a161fc479b5ba05bf8cca3982cc908630c23480a0d3bcc68a338bbc8b83ac119ff3ba73e85fa684149280044469b3b03b751b830706fa0bb5bc33e1c15b2c005e2b936e3be94bae59a0da56a0f8a44b5fe4798200c54059ae0055c741ba8d29947c7058086cb2617bf4c6cb1009fac43dbde9be888b990f149d8a4631dcfb6ec657c0592b85f8711a06e6b8475890d2433ea11b1ac4275ce7ab9fe8662017e0899862922053354a89355be71b6da36782f2920e59c1cc16c37ca9038735acf2b405ba1a5ed219c4e2919ef3f49d7a558f684309ccd5f35a8a831c4ced56d7fce5a78eb7e1883a14d4f2f0429cce8b0098b97b42", + "keypair_secret_key_hex": "7bb8136e0784d39448aab670fd4998cdd41670c6c04f3c2263920a80f08fde00b86267cb1e232aff0aa5af4199ec3b66af2b35dda5aba2ec9806864a654274fa5b83ecc64af4064fd1d4266cab2666e7ce0d713649da7c4db42378478707955c66d7a2de314f210242524b9bec46a1b238960cc4c341762b1ffb24ed10558028c2fb1683b21315fa8a55ed87520ba38acd71501a68c579cc48a0903cd710b989e83a3a029981914fb5fcc0e3b0426a9b13936b714215b13503084db127be7c24b9254bf0372a58351f276ab15aea424a6ba34ed86c6ee4c4838bc26e3948bf221f1f8a93b8b3acc41c28cde77ce2b075527460bc355f57e9ca0a048be4d108aea55ec7675abba90498c1293d90b8422a9f0528394697708c59bb39acafc333045de29a03667f395c1b12c10151aabc560863aca56b59a22427d700081a6f5bb268b6466f1b7c46ea2813890791f5b7464b60a6561b0c4d07495924c94503bc8f02c444b98c163a2bc19c932584b759b85d0efab10306c3cbcb5f7d552c2b036f89574bf3c5896ce73f79875632ab91189b69c448b0f95c648c05c33b0b0f8ec085af280461571a5bc78a0fc865fc71815fb77f2658580985a32dbab6b8e36cc9412ed36b623b29a8ffb69bc93bacd1b5a7859297598102be3ba18062b167c5bdd457517885a1bfe3b6c4418a9155be32c93664b0937b598ab1b0005b45151762ac9932c8796bb987663b2872217c68cbf205abd7063e2a7727ae21914c7797804558e8d0a16740874a837a8b651cecf254b4c62face44d660a601b4b3135f19c38e71506ca3d10416d15f9213e12b83e481cebbb188b44b86753211e443d5d3c8c6120c4168a7168d181c3a59bbc47b8332b04ecd3bd7f231a7f69aa5623cc0435c4fc3b16f59814c3960ce5ec3b78865303e5b432796b5267318d2497e1b08c9bfc4248d79a4439c587d248333b1273a10c5344a372ecc247a894926bc1e9f96bdd3c5046a62abb269d8a80853d220c75ab1603d96e5e42b1dc8b9ca0e72df2722d09417a3ba2c5537a19d4836b3df50bbb3836720a6b48a640a2d3bc2e0c57f3f6ccbc528e16923e4ad15041936edf60ab8cbaa8684c1024339b8330903edbb720bc368fc32593493c6f5bc6f5222a0138b90c70526ecb3c57798c59647dfd7a020e442196441ac5cbac4fd857ec5b3bc35a1b37315abc02570dab75033b996d2a36f806525c8b94b08077e711b6ab8a88720aba5d839ebd4ac777cba2695c2f80703c31b1a52e72b882866b46533abbf93c76d19f9e8ca845f9a2c2580054b1ba61d82cf08421492b719e51a482f028fac7063fd7c3e093ad495891e3e1200d227eb17602b973301fc0aab3da063be4169f5166eb0347b409b647e3b72d92907d2910feb030824c4cd298b13d6a5b90146c3a322c4638ce83b5561603b2610802445c073bb62467d501329776fbc32e4ea787dbca5a59a2424e87b1f0d10986e5caeef831b8b122a6213d68964c765742473020b6685ca2f556ac541956ec057649cd9c934cbaa3aab6252e7369c08a96a58ab14667b670a2f974a4a81250a94896188ce2910ea4550ba0b877e9d8439b1514ab4291580b7fc5f297c1f66760636ec927984eb565dbf90b5096c8cef429e250502a9769dbc45bc2584cf6e1bf8ee2379ce645b23175361844b0f98877daa7c689ccda931c035681d7e25296d57d8b1c984ff921d5305e97f3c87d48089feb0a0f005271bcab1e37c697f5122a056aa024689ce9217bbc06e0bcaf0b1a8a2f627f64472a36e16399f28c58fc6284bc327cd6b670b7902d9b4aa8b73337232a6daa5e77d81d9a284e4a5508c7ea5cc5e3605c3aa086c7ae5ccb7d4b8b67e8b02d1f20afb08a57c68ba8ca41ba92f6a5b5e8a758b90b57236df9b92e4a4706d9c34037b9b9e9c3c34f52c02e9975f6788d2d4a5e7de242e55b51e1e2694a4208cbcc5877b434b884ccd2c95621c898dff69e85a7453f24ac4435731b0b9fa5f4219b5b57807cc32e441f7e0a3f1bb3a5bb4807fad742bab7c3c7076b9ad44105204ada911d515a6712964f0f497e05a00510075757a2508fb14c276bacb0b780d5a92da0107cc3b0c4d4aacd69d33b0538bc5c4ac8c3e26df1c27054d1170d4ac946b7659ef90c14945a0b43100e663f008d33874bb9b403a24f10173c4907e6e8060267ab75ea4e8f7078e8571db2026b7a16309f694fe7332e6de130bc4b8e1a73678a0482d2939bf3e92b53f620e5915b22b94882c99249d1758ad63cf2c53c46251b74577a22b86f765844aac97d2b3c9be2591f7484608c492a0abb41fb52c91363cf1ba4bd8156cee3740730c417ce2056575bb5eb355f4b976ea181026bc21f228561441894852102667ca82bb0bd0f82c0655a2f91133630db9f28b26023234503232902d904b86bb9f3f715f96a4f08b34664d3352ccab6d6eb5075c24a49dc91302174587111d3723c8dc8562ff96f2c177ef9f7a2dfe484c1373b367490fff80a13ec76f97b8c84c40cb7a6760b3974880181b3a11aee337b2503287a104e3d29480a958935ec48e460953d9a5c83e19312973e90e195376c8a28e87109263e78b433524874185772de69bda0e4a801a48c5a50bf5215cc8d50b88e2b4152fa9b059909a00a989f03196518c516093593fab2b1e27dedac706886ce7a28a9da01a90c5b0c560c5c229a18dbd39b63291b45899e064a71b26c0ee47759e467af03a05b6f047eb32314c92aa6300136fa856a77f60e27b9624b932991b96e8e5c070e87964508ca11fb77f9f44a7b84186c7b27d19c18dcfc4ffa28b113801ace104dd5d03f6dec9e6a50cc5cd68c37094cfcab3b73951093f010d7fa3435aa676d5219fc77bcbaf608a8f0030bd9a253eba7081a0081764988d3b5ed3c920ea39018e87ff23a08799b45c6b9b6b3c095e2a7c9df7a161fc479b5ba05bf8cca3982cc908630c23480a0d3bcc68a338bbc8b83ac119ff3ba73e85fa684149280044469b3b03b751b830706fa0bb5bc33e1c15b2c005e2b936e3be94bae59a0da56a0f8a44b5fe4798200c54059ae0055c741ba8d29947c7058086cb2617bf4c6cb1009fac43dbde9be888b990f149d8a4631dcfb6ec657c0592b85f8711a06e6b8475890d2433ea11b1ac4275ce7ab9fe8662017e0899862922053354a89355be71b6da36782f2920e59c1cc16c37ca9038735acf2b405ba1a5ed219c4e2919ef3f49d7a558f684309ccd5f35a8a831c4ced56d7fce5a78eb7e1883a14d4f2f0429cce8b0098b97b42fec8de439e89407a9362ef9962ed4f6262151a8984c72fa6e888000cc127a6d5606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f", + "ciphertext_hex": "73082aac089adb6f1b3e0fc08d162bb6cef6fe57a1cede8d96c3fa32788acf250e90ce1022d1d8ce7b77aab8c15c966df3c99618d4228de1d15c28d487de122d305396230ccebab1edc47472a622de95cafd08b5fce2d683720f2e207af7c7d544cdbcf63acaebf36943f584a243e628469f81dd61b9f3ce46791142b17b5b8a23a69b84ffd2a34e2aaa31ba0a54ed5b77882d27c0c55a271f9c33c9cdb9e0134005641fb972f4c16cfd6ba6fea5f1bb013958033d1d9b519585defc975415e5f004505aae9d78903a2cd11772938ba7f0d49ac43a4b1cfeed64edf4794a0d41a385e4f6413fc1833fa005364c0cddc54208e3d07e42fe6a603f6512bcae082cb3e8d8b4238f1c5a6ab500d9dd5152a1f5459608b7037df7d8fc25bc54651f242863ac221438f55612669f89ee4fdd7905077498ee8bdc900095ba04382dce85d0c48343664a63afd025eba6ede1bb662e7496751bf029350c63e7f3b9f3d83dc82f4ce9787f4915b3d43ac43bebcd9dd9978b4aac7c72002e280a46f662679b55b49dc6ec2f4542bf58d6378c44a9ec7a2b134955ebea5d99c448acf1d2af9fda098f70b42511700f4ae8c8348c10bd13e603addad37f09c594b3773e915b4ef3c67321ccb898ed591d35b6f35dadcd84cbe6c81a6235ddaaf8090b1c61b57962d9670abafc5ef53488c726e4a5990595be311c50f784006e799dcb8f034da754930ab5d038ecd6effa833288b16876ae876b9bfec7e2d303accba1aab56e62146d705917ce717a8af8ad6608da4de73fc37aa293b48e2f25383c5a981b404e9d4c8a4beaabb2a8bc5c7a2f6a54b2ef96512e6ebfbac8ccf1f2982e7fb8ce26d740bb3dcf423d1655491a27a59020146fcc22df48ea9ac58182103ebb2bce42d283d5a31f86a39fd566e9fdd44b3c190f5414bf663169cbeb1b9c8adb5ca963c5f288d384cae7233618c10aa0ad1f80df3d7f645a6d6569ffb34d5da476cf739cc2d46e3c87ec273079e328e9fa6ecc5621cd2cc8927f9c9f2f914229b36719eb9fa485a7fe734502c99e2403ad0b417662946d6cfb792297e618a6de492f918237b82359f5a05e1b491c3ef7fc24663df3a081d8e4d5551b9b5f86f1465e6ca3d43462e5f0e73294daab279466614b44f94c81ce9bb3aefe602b945a004311d460091962e3fdc9cc53a7c633eaa16c8f212d39d6ac05829c67c69b99adaa2bd34fa1f8c007cec252c23e4f59c08a47b9932f9e9ab12395d2787dd9f34a40f2f2891ff8fcfad0c0c1abc4b2ea483f8d5aba49be24a88c3c3ce70769ccfb71e8950a764bd0eff82164e20784fdbad7f3b226eca2e483c8424e5590b2c71829073ca87acbcb9614f5e463c185e33f4e97770180dbce4e4e0f683e49475d079511ef2cfe48646aee6edb79f4460a08cc8dd3a9824eb963389568a2513719cf4975643d0394a6bd55c30790652f18c0daf23216bf097c0186408f6f0137fee639d40a229ffbda4c93f00f7aa65737f5d6ce0c3bd6b0bfadd25f3d8337de20c8eb8f", + "shared_secret_encaps_hex": "3a1f3500d6573df62d8f6d5c88d97e4af7e107d80ca170f6397796c8b9ec64ef", + "shared_secret_decaps_hex": "3a1f3500d6573df62d8f6d5c88d97e4af7e107d80ca170f6397796c8b9ec64ef" + }, + { + "parameter_set": "1024", + "key_seed_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "enc_seed_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "keypair_public_key_hex": "ae910c7084897478c1da794b91e4c2d4724797042acb354d303056e24b47918c2bef398fe32158ab569fc4ac5b8ea608450700f6b628b5ec26b946ba9ce410eae22933cb17b8bb4318d2524f28ae2cc649b2015b292b90864ab3ac491b212131ffbca74c2165fff26935197026e021d623ca0fc397dd707a6fe257a75c071f04b77a42907ac4a03ed10712b5ce6d200c61624b12254ab2d5cabf26c22587c2d0655bfe609be189546ecb09533a4256580490c55bb68642e0d11895b67472320d706c68552b6d1e71cf00f57fdb534376c395ad466c2ba2abc7819f366619c3ec4c61f967c75cc06e8a606f2b95876446543a5f7945aef5593150838bb9020f7de7c23d42cb6804201c83576279b6de0995bf1178826495d75cbc3b71c37b906a8894425dd813d5265ce0b97a620a244d472698a257bd4bb93298707f110937e314f42c892508c1e4c2cd499452a6720477788bf864828f527fee817b6af0003e33a948a1ba06225e3b72af84a210ba8c14f9cbae481a6db4338a18f2cf82124fab73b4a4938da6ea3266e7b4182465223b168559020954541fdc43ca3b8cb8c2bc34bba85190644da925e6fc27b8b3aa88e6702388086b6bad18673bc2a1740a9c63b0f97b41f37dce75109ea66b1f399fb867328d15cb7dc0081118245a13c603e439bc949d913cc9602cbed6d5a10fa0ce2c137416d61551ac89ac88351fda319eaac5849471d305652553a719998b3821831037ac3c14c09af8b393a40e8a937dbe31174ad9807989342c5b58c4b935a6b1b432e80c5c9933d24283376b81cf910e25534c6b570966d02acb728c2f71a2227c59c50a61ebd1aa05961a44017b76b79f9b9c5d9a1460eab26b6fc3bf69815ae44b0ab0a3ce6d8ba1c5369b29fc2b1295501317252114350895aa1b545c5773478305a57de13d84f17466512a55e84a5c2377bfb58561b11ab5ac275ed449e373616a807898d20598f4185f3cc45d5900c449acd3e85c7e96795c24177bc31f69f6c0c5f32aa2d6addd611d7950c06446a728597864eb4d97e42133a087b9bc68de4a363dfa1430212d2b44cbbc3b7e16621f9c46c82860b848897f1334872ffa69274bc19815cd6cdca272722d1c904f3fc456d55ba581c58bbec854c451c9f9a0bbf757142a3096220c0ba30715b21ba533657ea23c5ea4e265c5900845b92543869e9c001430eb81c43a43622823a743a297a9940901b84967cd45330c731a5322da7a96975aa4e9257a4b5bc616a18231485a4c963430c954008059c60d41e0220e55bd0cd267475cbad0357127c889ed982ddab49c82723376162990d73fa9fba83248921b206905c38ba6b598e321a16149a05e079f57fbceeb3117371b5c01b26648835801b56add1a85d628baba70cb4968a8a8b134e64329e326c0e2470753f6cd6c2478c8334c38e45b31688967e021ad2867f6a78d5273233383556cb91dd4c0a8f477400eac8c465a40524902b1e80a0fe9393d488625592147499091a823854b4b07c65b127158b3a0430b72b30c3aacd8b417201c34c82a5e8553a58329607e10cd799cceff3bc2fc329366c034e9f3afae93192b04b5d5aa3a0cf94eb9f9282e857fdb91064757398d9377a4eb093563a111729da568abfea43f22f642614a50f8c59413c6798fc58e1ba7c039b8782fc31ff738b4c6186ec536870252048fa324b2181d70415e0834092fba13596886a885ce1c214a46e772d504929a0b6b73e01657778ebc7b04262572ca526400e65237cb8bcc0aa5532989f9640a1b25816221281a238f948a1dd3aa81544339fca775ebd0a3c28329247bc0281ab6dcb34546ec3de5d998b9ba74d1d855260325de2524c3b1886375094b1ab2e9c2b75ff35551e080274a6512c7305c43073f584c8e5c5a8844c0a7b98ab9125a1ec5493276bdb3294611662382483fce73823336c3bb6b088f126e8d2779ab728361ba1bf5f757aab645fab3290a47c246a30062e14161da344972221535af64c17de7980a321ab66b711dc822360fab682eaab993827e9b69604e2339014c8c9365b8269cce2f45cb0e9bb4fe2799fd5c9541109e0f3a060140a76caa177481a5e8310554616eabf77c135b45c6183f15c2b68146bef8539e5254269c40b0811a60cf647d8b2878cbd3f6eeba676b21e0f2c47522292482fd830f330c1d84a794bb94728b2d93fe", + "keypair_secret_key_hex": "e610484eea5623586dfa55cce4373817548647c41e899ca653d212e126484157248d90906ae33a7171486e20aa5f6c5b31b2620eb62154e22ee0884a8b80cfeea029cf8c42458aaad08010c1b074b03b7eec8ab1925c81c49aa1d9670c93779c22c406690b31bee2b30ca24357951ae687c167c2b055e4b891079896505fd5891f8495193e135305899eeeca2dc52c3ad2b7b8de1631a53384bfd49458025ac12bc749aa4ab76b7ee984446d6bb82af5cd873b8321d8672d1733160aac8d0956145539a74b9bb8f6075f15ab5913c5d60a67d9264303e341df0b5ca3d38d4e581f41818361104ffd536412509a123c8f39e9b5bc0a6c61272016404bd55bb85b16961e49c44c0ba83d38603b28aef9519faba882a5d7bcd03baae070072b050f16b123d1e985cd02528703a3acd4bd11bc56f8f96708518b194bb1a0b47818c9401f8a7435e5b349a15fccd0ccecc9372ae139c5b524494199791c45ba722b5f46626ad19e983987ce36879e141e0c2313a135a721b25c8ef0074e109f0e3ab3f51b91b08b17ad58c5dd2b60d40bb545c3684bbab83ae1510531b11bd0176dcaa1f080c5bd2019abd5341d77b808903d89305e17f81388a091f7e4b0ed711096a0b62bc59338f6a051ea49ea321c791739a400a487f9867af2304f1bc120509c30c51b4625b977e41804163690917d7c930222f1c5281b65e5553c1668aa953b3505043d391886540c6180ec9dfc324adcec507a7041e3c6180451af46f6083fb037da016b8c8464ce6a94db65aefa94642b89c46e62333c41cf09195048f30c57473e86a3ba129798866107d7d659b2b073e09c03e740ac9c668a7cb591ce2277a1968dd58a7ba30aab804c3c23f62489728f96e87dd61c93615195a6666cc990487a7a669fa545c3cc7acd490e49c44cea445fdba3324ba32d05b5bf55caa705999867d1ae11620a527b71f1ca93bee963b5f91ad75983852c208e942ad3347812e67d32b336e511243d30aff3d30885a270f84c89fc056460cc73c78c996ee1c7c39378df080066531159ac93cfa529bcd94b6f99affb845f5d5bcbb021953821bc9d34aed1c2b527c42d37ab3fccca160707954c89a8e512291686a492058abaf194d5fbb6721a9628584a5524c36c6624d9a708d1e0a90e217ad6a0a7bfe7305348aaec980b36d40f41047432d1c2e3410044724eeb55b90717b20563286ae88b002db1bcfb39dd82b1b0242c8ceaa1c3476c69cb1cad65369b37431ada0704bb957c81a00a427f8e909a04b216e9317100f86d06804d0d649d4e442562816239f556da19157980564582021443a38cb2c59b9b16c77603f1026e95c19793db679bea1a598563c2d959240364c8c55b0ab2932b0a789ec06408ecb88974ab51f4cf82dcc82b728c1ca2a294745fa8b95065f375c6353bd8010f4649847f445d9e6b85d9334f8f954f0c03bf1c1314aee78fb2a61dc4dc05b539abcb5abbefa775f123a9167341b19451f8299e066b97e4c691dd272aa5539c177128215b5c5ce697ead513da8992a1236b9d8b71f3651a516a1823587c7492b06ba67a7d545ecba51b58eccf29e915bbf159f7f01c68142f0acc774d9cca460212f1ea91378c25a2c7570ab5985aa2680c0303ccc62157b0591b9302bd1234ff2b4125316916728c2408036583c9db9313772c3b10374ff4540b03d38bb2cb4b1d2654baf33b9626c4905a6d68280388357523229ea74c37b7a6672b351cc3272708792a1bd1bfb9874c2f6b70728a5ca3447509081110072a864b17b836c53ff348e4b57ae087b64b027d2b49a7d076be37f39a5387856ba4c6ee7b96659b62c9799d266a1618b4cb70eaa58c5407f1426b264cb2e83c7d623b313d27b20bc9c100681a209b9c84056b6e17978ab1203cc1cd6035395687957da8633a5a4396d33fd8c370dd7793b31680997101253558ac9a8f364528faf09a5667c8a1851a96581378b002b956109ca2c911ba58e2f8aa638665aa041889b635dcf163d0d4623b11b591c22c782068794614214b2387528e14628bc4c62ebc919ae42b9d2974a9e462b9d5ea513b64026536bb209c1603e7a76c57321f512513c8723ef5ae4559901689a070ba0b795600bd748b5ea005fcbb559aeb4d1d109826767780514d9bca90ae910c7084897478c1da794b91e4c2d4724797042acb354d303056e24b47918c2bef398fe32158ab569fc4ac5b8ea608450700f6b628b5ec26b946ba9ce410eae22933cb17b8bb4318d2524f28ae2cc649b2015b292b90864ab3ac491b212131ffbca74c2165fff26935197026e021d623ca0fc397dd707a6fe257a75c071f04b77a42907ac4a03ed10712b5ce6d200c61624b12254ab2d5cabf26c22587c2d0655bfe609be189546ecb09533a4256580490c55bb68642e0d11895b67472320d706c68552b6d1e71cf00f57fdb534376c395ad466c2ba2abc7819f366619c3ec4c61f967c75cc06e8a606f2b95876446543a5f7945aef5593150838bb9020f7de7c23d42cb6804201c83576279b6de0995bf1178826495d75cbc3b71c37b906a8894425dd813d5265ce0b97a620a244d472698a257bd4bb93298707f110937e314f42c892508c1e4c2cd499452a6720477788bf864828f527fee817b6af0003e33a948a1ba06225e3b72af84a210ba8c14f9cbae481a6db4338a18f2cf82124fab73b4a4938da6ea3266e7b4182465223b168559020954541fdc43ca3b8cb8c2bc34bba85190644da925e6fc27b8b3aa88e6702388086b6bad18673bc2a1740a9c63b0f97b41f37dce75109ea66b1f399fb867328d15cb7dc0081118245a13c603e439bc949d913cc9602cbed6d5a10fa0ce2c137416d61551ac89ac88351fda319eaac5849471d305652553a719998b3821831037ac3c14c09af8b393a40e8a937dbe31174ad9807989342c5b58c4b935a6b1b432e80c5c9933d24283376b81cf910e25534c6b570966d02acb728c2f71a2227c59c50a61ebd1aa05961a44017b76b79f9b9c5d9a1460eab26b6fc3bf69815ae44b0ab0a3ce6d8ba1c5369b29fc2b1295501317252114350895aa1b545c5773478305a57de13d84f17466512a55e84a5c2377bfb58561b11ab5ac275ed449e373616a807898d20598f4185f3cc45d5900c449acd3e85c7e96795c24177bc31f69f6c0c5f32aa2d6addd611d7950c06446a728597864eb4d97e42133a087b9bc68de4a363dfa1430212d2b44cbbc3b7e16621f9c46c82860b848897f1334872ffa69274bc19815cd6cdca272722d1c904f3fc456d55ba581c58bbec854c451c9f9a0bbf757142a3096220c0ba30715b21ba533657ea23c5ea4e265c5900845b92543869e9c001430eb81c43a43622823a743a297a9940901b84967cd45330c731a5322da7a96975aa4e9257a4b5bc616a18231485a4c963430c954008059c60d41e0220e55bd0cd267475cbad0357127c889ed982ddab49c82723376162990d73fa9fba83248921b206905c38ba6b598e321a16149a05e079f57fbceeb3117371b5c01b26648835801b56add1a85d628baba70cb4968a8a8b134e64329e326c0e2470753f6cd6c2478c8334c38e45b31688967e021ad2867f6a78d5273233383556cb91dd4c0a8f477400eac8c465a40524902b1e80a0fe9393d488625592147499091a823854b4b07c65b127158b3a0430b72b30c3aacd8b417201c34c82a5e8553a58329607e10cd799cceff3bc2fc329366c034e9f3afae93192b04b5d5aa3a0cf94eb9f9282e857fdb91064757398d9377a4eb093563a111729da568abfea43f22f642614a50f8c59413c6798fc58e1ba7c039b8782fc31ff738b4c6186ec536870252048fa324b2181d70415e0834092fba13596886a885ce1c214a46e772d504929a0b6b73e01657778ebc7b04262572ca526400e65237cb8bcc0aa5532989f9640a1b25816221281a238f948a1dd3aa81544339fca775ebd0a3c28329247bc0281ab6dcb34546ec3de5d998b9ba74d1d855260325de2524c3b1886375094b1ab2e9c2b75ff35551e080274a6512c7305c43073f584c8e5c5a8844c0a7b98ab9125a1ec5493276bdb3294611662382483fce73823336c3bb6b088f126e8d2779ab728361ba1bf5f757aab645fab3290a47c246a30062e14161da344972221535af64c17de7980a321ab66b711dc822360fab682eaab993827e9b69604e2339014c8c9365b8269cce2f45cb0e9bb4fe2799fd5c9541109e0f3a060140a76caa177481a5e8310554616eabf77c135b45c6183f15c2b68146bef8539e5254269c40b0811a60cf647d8b2878cbd3f6eeba676b21e0f2c47522292482fd830f330c1d84a794bb94728b2d93fe8db2bf117dfa02aaa1994384bbf77a1054cc601b1d3c6fb6b215823f55e6e7bf202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "ciphertext_hex": "146e60c3d43051c284aaab2fb366c6ab5f0cd52c298640e7d3550f96747f18b0e492092d5938289c00d7892b0fc770c451e50f00912e137f98174043feecf83077a9184a8b2747843020d8cf0a7c6efb4e288b563b088e2b6ce23ffba32c0d2d81625a7595b31a8b5a5c0ea7fd36b37e96bcf6281dc8701c6d5ee1dd52476c561a5d02e59dce2c15b67f46043628ecf8bd2e06316789a0249fceb5425441b3d48a7a1356f5e37f690ecbd869eab4f0bc5c0fd8c6003f62ca337781a27017ec48c4db5c3526873e859edb94d9d6ef86788fed64f63362710004add57a239c5738a42eb114f4680a55ed1579ffb53ba6e0c452e1f763f017daa9763e187c30828fc9af9f5d062400609659df99e546b6d3981b8663bfd2b31ec150ff7613259edba1cbeaf01aa6bf3fe4bbbacede1ff098be17e882d72cd0f6379cec7383f743f4860de7911695c0da990f11b72c28e9cfd2156ca5b72ca7cd4886d8fce4ca29b191934275a0939df337a92e8aa93ee7b3b09ad16fd38cd58af4cacb73ad1a898218e445f85e9ddc203ced37a2b6e785669bc1aa6ce695dd36e5b8962e466c230fad27f3d59b308a9942f45a3d2dbda764de18efd0608a42f17946ee38c9161300275a43d9125065597200c418f93491be1e98769896fc9b5c1055c2a6a03ad2c6b3443f170790148021137c19faefe6895df52de19afe675fa898613911b8aab992d81ab628b0f1fa43afe3071f94b18c0f1193b175d34813584d46fb13dec92501b065aaa62d93d8a97beb8aa5d21194fe4388aa2fc5a7b7e139f24dfea8a77965ac1cdbe57ae746f5a99d639cb15ab16bb6eece70d2c05fd3b4d71053c232718da477bf435160ac42657850692824937e9cef023e79c01124481a7094086d9c1e7c36cf158fa8c281c8aba8a6c4477776fb44ec8e277381c598910d73137f4df783ce22d2446cd9c24ad18c5f0288409b59a33e2f4e56c60ecf96608e095017186464dfee94afdc7e2c76828beb971e1e5242716420f34296e6bd4e32253d7ceb54da595936a19760a2b06f9dff1bafc137af81a60a6ba771adb8bac3a384e0c8ba9a9f9ac52f621cd83b4f2499bcc94a962f1a0adddb4b0f574c5e8e24fd3d05b9d7f93e3fc707daca4281fcfd907b0ab6e25d88bcfbce8d4efe4a1ad2dd0f38cf7367ba30662e5266737182e45408a749dc8795e5f093044cab57d3a6568a3fd7c6abc5b1e4f8dde7133d416963f20669144f6672addbe483dab125f06bb0e5d5262e33a5ccc143c147e47168c84421bdbf47c6f9515ffdc52385987570bd1c65053273db72db716f4d7c97a0b9ff064aa6a6c74b961686bdc75d651745d9c658232d05884306945307e2fac5586bfb7c286b35ebd8549c674142a1430c7ef77fb4b952a70bd2f2680c8293333e3fe61a62b4742692b4524a066e8bcfb82f8ca51e1a34f605bc3b05138d43ff1b32403e29082829a9357e0f9c69ddc4f3fa598add44dbf7aaf6b5dbc73c36611edc3f1629e2e41aaa8be1d3b0cbdb0535b49a2525ce86ea2b96f59381288eb5a85135e83cd033fce799e3fbf7ebd80995e8e2fc0169ca80dfa6207d801805da4fa9ce19f4156c560939688cb3c8c8f122f3b64159da21d8b1052aff703098ec92f640ba9e711d47b3e433f67683f6b1efaefebcfb0c3ad51287ab01c8ebbf94469bcec07e917c7e8d700ac78127dc54c26dc9f1378a0b48f4fc0088ec5671a2d04124c5e603efe36bcae3d1c7293cc4b45282343f64321c21f57b875f0c6bf771d97eda20baf51b69b65b018e907bb51da2938b8a211fc90749c85b7dc1f0c1a338be93e8c01c73a1b9ad771711ab208ce1be645fe52c4d368944c72a3dce873aa27c1e64f39e74d8450bd984f00d2d0064746dff1d42952c7877081a3e6b1c07a9927a85584492de1029b1342ee1ae4e5bdfd5839acb1852df7ce438cacfd928af13033443c509bda1e06402e2a9243b2719254b320ad3565491f2c454b9b651234c9c5dd9c86897a12b4d134075d13cc91cc9806b85feb7ae2c6ea8edefb18b6ca47370f23faf667986f3e848c2234009e08fdccad0c264980c262ff593892eb99c58c802723ecaff428c1c657d269b5955d318a43d9ab10d85ecab69f4b2040cdafc8d61224059b72d2cb4ac4e531e18e8d26e539a45455ef6a80df316679a16f4d26adc0e965161816f33c8891e023c", + "shared_secret_encaps_hex": "51cc830ffd066650f8d5a84fc652f950c119677b9e0fccadf3350b36a3c1bb2b", + "shared_secret_decaps_hex": "51cc830ffd066650f8d5a84fc652f950c119677b9e0fccadf3350b36a3c1bb2b" + }, + { + "parameter_set": "1024", + "key_seed_hex": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "enc_seed_hex": "0000000000000000000000000000000000000000000000000000000000000000", + "keypair_public_key_hex": "e4b43dd6b402b61c32f0191d13b1b54c50b8dcc224482864d7c818f7a242a8181e3263bc4332501b2359dba9a84218862487a54b4a6afd4aa8ed866800e1af36d7b6d3498121740738c4cafb10ca54743f4be207dda52ea1c9b14a36819f5818915b746fdca9757a4b594769c876a530ec95fb568acef03f900499b0b2caaa0158be137e864c95461b3422e8cd0a888ca7d302cff86a64038fce2891bab669d28104a8f6a1b864a18e037b3954b7561845c44a318e8736fe090df18a7db7db528f245d09e901a57a40c93a0bb1772480b789a49825f7d47ee57c73b5f34c801924eaeb60e82987e0b96f50694534982393d70baa6765cfb8c0e08117a212a7e80c1416c4b17fd655cf6289e3c61292f20a2c829d9404c96a3411830b9f204c0102f206511877f46a8931dc015d0003fc78b1e655123ca014bdc4c392bbaf7d1c4e99e793f52228afe3c934f4c4c33636a560937ec0a265a696625281df607f0a4c0662d3777003502f474eb7e894caf4479feab418b1360471b96fdabe8cf45f3c59ce79992ef8ab1f4a8a1dc1e030baf9c0217142b7c93855d8341f4923cd276693d3686fc4c96c4703ad604c3438582bfb036fd962b0f8a0f4d6223b0cb0383215e7db8c7582933933ad095119fbca1f35d03e82562a4e17204a455927060aefec2596c6ca487b3e3b94cc4d3b1584b79080555c879c69aec2a8caccb00ef023aba886721201342a2be68b67c9312480441d9b71568e690e20728fb22749ccca948679aa70d5acefe38a91e026f42508b449c719d57027fa42b623b280146991900c854b725dc3b656c4b9e7a697bd5719115b7cbfd26821493505db5f9e40a44f2a38177c3bc4525bfe05606ec65b77294c16a18c939453c66141789a9db5515a2d230a34a1ad5e622a0c27cbca07c49f359e4f255fd7048a93e8cf120864d069664cc30be261b631a911436b491ef01986ea3bf8915f20ca526641cb44fa963169b2099b307f86131596024988946b014e12f67465897abe2504f2f8139a7b1c94bb5ffc7324701ca4a62bcfa342bafe536fc4022cbcb19a2b746fdd442c456b98a2776f0c409f94430d33a0bd9436381f4943bcc68c6ee08957c1b94c954374e6b01b83af8b3b2be01847e5d04f742bbb30bccfb8528b58f97e5ab848ae683001660e69b4bca0602cbb43b3d5a288aa8b938c26670d31a79f704a14546b52fcbc8169a8fdd540660a3a56e36939a753d639247358adb6b4b7ebf097964825b7ea8005680f2ae5820a6bcf60a615282b710736bae6bb55e748ad3fb73dd3aca03e0a68f6b713cfa61b2016ca8a4764af256eb9f876fe6a703fa164d6ec9a27c684fde5001d7594df7004591046b3caaf6548819b6038cdf378a033b87c8c1a45045713125d4e617f7c52b7dc270c7c552adb032ef63811713b066e33c2c1425b0214a1e3d8cf69b5c337d16b12d36460fc3ad14c2720f869ae4ba294f62c67e24740b89352a38a734cb535b58b6d94568d2c65a6d6623bb5c905513f62201d48cb60a946c97c865f96c9c4df39334523a1d15395a90195a3b57b34b1b7c127944c2a2a39c2c067a714ac13cb768a68b4ab6a973469f0a442d7e07a70254329d801926927d7fbbcc888476862336ea2026d68aa348b4b6e3cb972d42cc098291479a4be24b233a7155fb9aa77353ebbc64f0e0aa0284acc51f5c926315e0b440079a7619bb4b2e80880d51c1c18cbccaea04de7c569bef53161e3c3b7310e9137530e410d40ca55a6a51f62e2214e622bd0255b71ab5944c50f92b6342ce7c2f6eb6c018770a9080e20546ea9812b7ce65c7aa5ae0aa65d2f9036a9e45c7dda0dc32b311365bacf489d9e708802b1626c392f9aa00cb6d5a237f3bca3908c0c853993e94a558a0551a375555153d34430b4f5a16ed4482d950630ca3fea061ca0e5836d1812700bca26d36d920a3c49d946cbd8a1f2010a3e8b44e49b1428233a7a3a7e2c0964ec47cac2b6a527310fe3196145f0b700b1bcacd94b8afb9e1f3366acb783f37abdc898bf1a6b9ef48b6071e29ca9481c14830403964aa6f2aeb22342c0816d7e02257d41a88f790a1372719742acaf760d7b412904644d8b0b9c7ff33ae7aa0806a7454a38c81cb5319bf34690212b06e3b6f6ab7f05e01ead56c35cab5063b9e7ea568314ec81c40ba577aae630de902004009e88f18da5", + "keypair_secret_key_hex": "891865aef10cb22387cae735180b85d14c436f931790804047f7363715b2c1c430d39667f765af885a7ca5aa79dc54cdfcbac3b7aa5b55868b79d69498f858a73476a605877a1449f58ccc25107367ab3e335202a59a7a40d7a24a107d099a1980c3164150b3b7c5339a745f3ab31bdd9c7d23a38592287bdd113864a41bd72845ca192189fca702545c28f7a7cc670f81c9be5584325074238380c469055b6e3c42e8288e93830a8b1c29ce858dd8a0a155a1504df4a82d893be8646c2321344638900d2916eea42f4c35ab16e77bca81a5e2f8086e5a5685e8a839c0960ac0574ca43d79fbc367534a71cc7e5ab40b8eec306b82c784c8b2cc48068cc165ad4cc039d934f6b222cab4971177687d2075d66abd8ba0ab7fdc5caeebb9aeba1b50c5c6ecf7c5ba6c242b444ae8375acde408fe61617bf87c7359ca8513a2b8a33b5447298b063fd8cb15cc0059cfd38679e53447100baed9a5e41986bff15aa11837214cb6b239bc74c190df138904d96f5f7305ff3cb0cbca9208f79163613eda4797ce8c5b2b257ab13c4aca24b367a6617136bee92ba57a22c459259095853acd53ad6e739efa51312ce685a180b522e4ba54f847129ba97c171979b008c802b0e1a33200e51081c18116a5213ed386d47c3414278957cc0e18d65b9ce866f34aacf800be7cfc7bee51314476881b49422f2c8cad276d24707b996ab4ffe6c50a4c2323e6563b602bd5631b5bbb4cb60311ec8564b715442e9470dc906c0e184e2cb66b5ebb4e9b9b5d726247f3a77b46844ab6b1ac88fb84a263c694407031e223cba49b51a624612518c2106c7ebbab910a14a9b120dbbbbb1dcb705735b37b828e17d15da3d47cd7a5c3e6d40504a4b65bbc10a5699d11d29eebfc862fdaaa5f71bec94170ed703717b40da37436f2cc5ecfd1905a824d09703092a0b602c7cf480986dcab5e038729a7b3213cdac265306685455fa38bca3550739bf68bac89989d087339e19c4b6446f8e96692396cab453ec1238ed8a796c9f234ed2a38a1e51dc0978dfa9c6099134b49197f4ec86b9863962e857f62c715b3898a2b996ca446772203a9c1e15bcb140d959a7cd60c9952742bd30872b22897e3037795da5ab2c2485f41ba8e2c2b0879509066ae4b509025fbaf9f92b5744729da57c40f195ca7bac0910c680de01ac5665d93b04404a5702f94431039a9cd7884a7ca74a76046a5430e6b4cc43964b40ce0260631572dc7c45cd58a2c9c6df8069d4d6c59ba418f76a9387e7712ecd65630f6c0ecb67893e06ddd68851c3c4939e3add0d2989b76b11032cb5aeb8d61195e55097bc82a4c26141869a5ceb8f3805468c1abd53bc9b198d9a528238a8e9f57811963ab56c40713396f09d917dce78f88bb639dca488526a4cb9894b6a50e1bc38476d895af8c0733b16d851a209453180fe318ba7371137688974c6a229179bdaba129f27ea999350f952e4eaa62e489cad6d969984842942338554a69f9e41659a6c87e5b284a8173e9700219ec298f812cb6e350b6d314a83017def1300ae57ada03af9f76534eb82d8f4abe9d10a23f2656aa73cab38497725c408ba0429aa85a374b449664288ebab79cb3cc4de15ddce0482b12127ed14106872c2ef93cece980f9ac2327f8c3aec7858e89c9957b5c15ccad712904198440faab535cdb2bb85640a69bb0467c77e8575bde418db3a1364b7b41b6d67dbc61274cd47a4b00c74b347e874a76b58256211648072b44dd06225fd929f5863f3d3280e93787d37620c33350fe26596805b33e793e6fa886f4925d0475089de91de7515afa2b0e40e592f0967d0f20bf244358a857484c6855e1079ced6636080673f7219d5e59afa1055ed2e478b83201c591284bcb1f5032566ebabad3bcce471ac40537a1a08cb43e3aaa7005ab392375a1f770dde1923f071c88c33e06bc1349d3cefcc904b04b310c4ca51849a3105a99c7f90e89732136488aeff7b71ea1c019407eed3440c503be3fbb9bf587113d110473c6aa3fc07a910b78971994d087a17abb8059477ba3c0b04bb30929d98be0d43c08fb1615609c5a7b80a47ab8da97a139c1c72612b6dbd8ab215b718803beac4cc7865007f3983c590c68012c27d8230a6a279221c29e70b017b9a6be99e088e4b43dd6b402b61c32f0191d13b1b54c50b8dcc224482864d7c818f7a242a8181e3263bc4332501b2359dba9a84218862487a54b4a6afd4aa8ed866800e1af36d7b6d3498121740738c4cafb10ca54743f4be207dda52ea1c9b14a36819f5818915b746fdca9757a4b594769c876a530ec95fb568acef03f900499b0b2caaa0158be137e864c95461b3422e8cd0a888ca7d302cff86a64038fce2891bab669d28104a8f6a1b864a18e037b3954b7561845c44a318e8736fe090df18a7db7db528f245d09e901a57a40c93a0bb1772480b789a49825f7d47ee57c73b5f34c801924eaeb60e82987e0b96f50694534982393d70baa6765cfb8c0e08117a212a7e80c1416c4b17fd655cf6289e3c61292f20a2c829d9404c96a3411830b9f204c0102f206511877f46a8931dc015d0003fc78b1e655123ca014bdc4c392bbaf7d1c4e99e793f52228afe3c934f4c4c33636a560937ec0a265a696625281df607f0a4c0662d3777003502f474eb7e894caf4479feab418b1360471b96fdabe8cf45f3c59ce79992ef8ab1f4a8a1dc1e030baf9c0217142b7c93855d8341f4923cd276693d3686fc4c96c4703ad604c3438582bfb036fd962b0f8a0f4d6223b0cb0383215e7db8c7582933933ad095119fbca1f35d03e82562a4e17204a455927060aefec2596c6ca487b3e3b94cc4d3b1584b79080555c879c69aec2a8caccb00ef023aba886721201342a2be68b67c9312480441d9b71568e690e20728fb22749ccca948679aa70d5acefe38a91e026f42508b449c719d57027fa42b623b280146991900c854b725dc3b656c4b9e7a697bd5719115b7cbfd26821493505db5f9e40a44f2a38177c3bc4525bfe05606ec65b77294c16a18c939453c66141789a9db5515a2d230a34a1ad5e622a0c27cbca07c49f359e4f255fd7048a93e8cf120864d069664cc30be261b631a911436b491ef01986ea3bf8915f20ca526641cb44fa963169b2099b307f86131596024988946b014e12f67465897abe2504f2f8139a7b1c94bb5ffc7324701ca4a62bcfa342bafe536fc4022cbcb19a2b746fdd442c456b98a2776f0c409f94430d33a0bd9436381f4943bcc68c6ee08957c1b94c954374e6b01b83af8b3b2be01847e5d04f742bbb30bccfb8528b58f97e5ab848ae683001660e69b4bca0602cbb43b3d5a288aa8b938c26670d31a79f704a14546b52fcbc8169a8fdd540660a3a56e36939a753d639247358adb6b4b7ebf097964825b7ea8005680f2ae5820a6bcf60a615282b710736bae6bb55e748ad3fb73dd3aca03e0a68f6b713cfa61b2016ca8a4764af256eb9f876fe6a703fa164d6ec9a27c684fde5001d7594df7004591046b3caaf6548819b6038cdf378a033b87c8c1a45045713125d4e617f7c52b7dc270c7c552adb032ef63811713b066e33c2c1425b0214a1e3d8cf69b5c337d16b12d36460fc3ad14c2720f869ae4ba294f62c67e24740b89352a38a734cb535b58b6d94568d2c65a6d6623bb5c905513f62201d48cb60a946c97c865f96c9c4df39334523a1d15395a90195a3b57b34b1b7c127944c2a2a39c2c067a714ac13cb768a68b4ab6a973469f0a442d7e07a70254329d801926927d7fbbcc888476862336ea2026d68aa348b4b6e3cb972d42cc098291479a4be24b233a7155fb9aa77353ebbc64f0e0aa0284acc51f5c926315e0b440079a7619bb4b2e80880d51c1c18cbccaea04de7c569bef53161e3c3b7310e9137530e410d40ca55a6a51f62e2214e622bd0255b71ab5944c50f92b6342ce7c2f6eb6c018770a9080e20546ea9812b7ce65c7aa5ae0aa65d2f9036a9e45c7dda0dc32b311365bacf489d9e708802b1626c392f9aa00cb6d5a237f3bca3908c0c853993e94a558a0551a375555153d34430b4f5a16ed4482d950630ca3fea061ca0e5836d1812700bca26d36d920a3c49d946cbd8a1f2010a3e8b44e49b1428233a7a3a7e2c0964ec47cac2b6a527310fe3196145f0b700b1bcacd94b8afb9e1f3366acb783f37abdc898bf1a6b9ef48b6071e29ca9481c14830403964aa6f2aeb22342c0816d7e02257d41a88f790a1372719742acaf760d7b412904644d8b0b9c7ff33ae7aa0806a7454a38c81cb5319bf34690212b06e3b6f6ab7f05e01ead56c35cab5063b9e7ea568314ec81c40ba577aae630de902004009e88f18da5102776aa21a3d0b97c61c50fb83ce3c4c46bc74e184a04355b466350823342ba0000000000000000000000000000000000000000000000000000000000000000", + "ciphertext_hex": "61a9d96d60d299ffc8d7eb01be3753c7795328b3fc579a1093e7d88c8f06f6a642d9296bd97062a0af68697e93d2a95d830f07372b6cc61fc3c1c91d8e31508907c0b378ce813dfae5fb5cfa63142fa858900e1d29e0343876021e423f5fb9440aac97507ecd5f4c6261c8eb08c02c79886a1e1d32177fb5a885f1af94a4a480d11ffd4380b3f057c51455fd7dde92837f83e6c94b3226774695973764e49f23e313ab946f631671b31590e189ce32e48740ae245bc4d17e19594aa5b07cd3d25f268dc8f399556b10f59543d9c8cb1e5087a1edd256bdcb8a4cb0a4780025bc8006311e0334b94347d06a26b70bee129c253582c495567ab46d5fb62176a568bd5ec490b10975e7f268eea10d82cd8d6524fef067065fa86da73dca19b7e588ad7c98072557c90ce7097df23ab7074facaa821e7c748518c9af3957620c7f014b79974f61c659beda82d589356432dcf916d218df2041cf32d6a7e8d2fb4a4069a7138e0a9630d61344be5aa47ea09c08e4b04df162bbbfea5f527cfab7b14da2d75730b99b1be9cb4e9abba6d927464a3a3f19c4e63b02da7274f748a81b105fbe108350fa977e2584592104e518c82e173bd600f8f48a7dfcb6652570f8460e0c6569fe08a96b66fc430ca3bf57432f238ea335bf733bc86f5fed9016655a92b5dbf5de627b6182aff9328261674b7fc92e8d971a971e3e921f3c066db3cd09f723dc394106e4710118957a55c4c6a56d164d628d2b8b82878396302bf1d13e05c0aaeb6d70aca36b116ed0c0ddcb9d391965cadc6bc2ec14abecbe1e6e438495c01297d085634b5995fa486560d50cbae35d1229edddd0010a4b1d5e7de53c03dc28f2b89bca626e338a20e36ffbf70729d67463939da863a2d41947a77af596cfe253ee60cbea1253715e3177ae96385e8838cf00ab9ab6c4af05e9f91f69fc8732d275a67d02c27a4e78afe6cd8368a2cebb193ccaa91dde89cc36096ba0552f830d3a6ea5aa2a26645893be9d34efbb78a0b7bf9098e693e158f7d36fb2514e4ba4e1b1284d71ace85fdcd57f1c42928f46af51a6ebd622fe4f02494ebbe7500cd4529dbd57f0f534a2737b815ba2fc75c5b3d751d5b7ff2324141617264ae15c1e75cc8815ad730b9a414c789a00104ee67e5b8e3f308f83b606194692f349471fb93893ffc2c3ece37699e36789caa006482d3a65e184c5cefcab16509b7b5a241304660b188bd7809ffe89c1b8e068eb4967c0053e5439d686bbbd4a4f5426dac8e46ddb317f741c5034f91f19c5038377f9902f59ad41c76eedb588673f64414e53339d6e420e115c4810b37f37b25f6d96eeeeda8d5993901a6ae7184e08514040432e56e1cac39a5601f287028d4804b4be554d524ba52b37dc095ecd2c265417f5197cced2e1511708be72be37cd79443af71e80b05ad0b7e4fd897d65a04145a8c30f9d124d7d7f7b16e32dc38afd33aab4566f757763952af9dd2229d839910a89a640afe8a5fbbe0e160a641f7975ce5515c88b67fbe154e3ba95267b97d9dba607d6f84f19cfe7d338756b91a1715be688487ba79de8ddef52c4061040175bd2d6aa264e3c9971b70af804eda08c65e88d7e10614816316a2cac0c984492be23071d5a8025b18dffd0debe772f8fd01d34bb7038b4261a44ecf8274b8d097dc4e3fd42694f642b0e06028bb526ac210d574c0acb853911e749696babb3137d2cd4eded3f423b586b8dbed53146e4a84211e510158cafe823591d37d5e3a0e08a7936aefee8ce21c08a46e5c1320c1fc40febc3e2b1e9306bc9a99f58404ce0846756187e8d1fa5fd4f3a43be0a3fd91646e1d241e3926f2d08a961ef6f0aa6d86ae1f52115e79fa3184686a5f0b9c72b05db9ee20bc4501a72441b5e5991a86f2aee444c7a3275973280d9ef5f725c419e4c0eeb418833149dd48689a5c3075c1887577eb53ab8d894f7e0d7417fbb9484e554d166b5946c15bae7e9ac26af116cfd53eff858016e7bc77bc80cbe7611c8239c214c0868f5270f7f351ce523df445703094a4604455d97abfd5bb85b26ab2ea076a61a957c8f70b3b6d334b949fe038687de3c20f71982748ee07c0044ba5290fd2a3424de5005e250c7cfa08980a9d376fba8e0056be33a3abf3c115dbd6ee28abae426373bab5f17554f9e2ff4b331ed1a3eb4c76fb6abed59b10be560ac79177f6f5b", + "shared_secret_encaps_hex": "76ec4a122ff2f400c7744ac616113cf5203fae373b131cd02ae5f93d7d700b7a", + "shared_secret_decaps_hex": "76ec4a122ff2f400c7744ac616113cf5203fae373b131cd02ae5f93d7d700b7a" + }, + { + "parameter_set": "1024", + "key_seed_hex": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f", + "enc_seed_hex": "202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f", + "keypair_public_key_hex": "395a43d2716a696b2c24b07e5cd8c90f7302d2f890545310a30c9557a9c19f4a024cbcae780c9b9f80a4037aa7b8526776b64f68224379fb8d15ac4f53297e7768938be838456ba62d4168313162df1c7a51375a5299110f8c951116925ed8a6b1f2891df7476b6a07fceabe6a7481dba1542ee134c706aa2c4b851cc893c3a2541bc336b31452c068c1127cb808d465dac26421869df8599bab1633f9d2b0569acba7843686014e7c621e2960be20a0871b76111fc0c97d601743e23cb189b319f96057fcb1dc7290d0d4bf58735bd196c8f4a75422ca2375027996d26ab4a24df0e9560c1193e301bd93b77ad4a12aab86aefcd11f094b7ebd2833c1528a350cc10cd377dca0781cf1bc072650b916a1f07b4f96a72779ebac2e081697956203c55f740095aba5c1dbfa018d43251245390651ca929148fc195c0c1169a2b947af09cceb0c13a775413b59b3f7b292469c155d49a9ed71b6b7d605c9963f78984342413382974512741bc32ab63f9b443b4bb10c4a89f44747e86b3bb009b4f1a84cfea1718cfc89bd876438376f9d98c0c259852b160cbd4804cad47ba44b989af1576730905bb72546834c232177ff6b7a0c7444589cb12ad6cfb41bc1ee17694b9b363f439e3f3a82f403cd3ff8300ad668cfe219e2782b83c0634d61594b970f55f2c1cacbc42aa1021edcaba002c1ac1ac152880701c04134c1b8046951a48ac55886aae60a050ff2a3b5344e29952ea5d08a2af0afe3842fd968436eea4fe2fb521122a2ee06b6ab87bbd4a4cae2d7423658093f2a12c71b11969188d2e298b567bc55318cd3202ce69216570507b3506d59bc6ad015589e1a154d93651788c91e91035582280ada89e6271aee532102563194a622b21ac8c809247d32318f82b3ff812e72e3365a20740ee57ea2135274050f108c59e2066bd782580d501d75655de709a4bfc201b82230a5c31d8332331a8c1ef1f28b29e5ca67c5a1a99521eaea3d941a2fc3f437928149cf8c37fc77918e91b152a1ad10372eeafc8c6dc27d0ec40154c72c96ba98e9aa8b82d9c0f05b55888b0bd0934dd2f730e7a26148c70cecbb00b50177577c347c89cd151c7397e3009ae28144e19bda08c5fd85996402467b994f5d651c3734a617a605f32a76b1b035eec72acad48c3b5c8b41f662531a5ce0b37b38d1253cb98de3c41107b1ca31a91fc89bc0c21602423bb064649fd2bb9fe7d72f79229be92596032c9ea36c7de754aae52004490105130497b0e42344cb8342f0ac09e20b165c2846c52e75b17ac4f84fc5f41dcc454225f9b010e95e75b44318bc9996763ed5e020d4a654500b071c8a1df575c1fb173bcbbcc2d1527fd0e852a699c11414557b190a965ac092e1a24b001c01d72fda0a85bec0703f85254f998bc399cb57a48696d753e0490597482d64529e8dc585804164a3dc048585ce02d9596dc80fe58396a8922232c91682297daa53bb2545c5e949b532163d8fa63b77c51dec7c3bd1b70c83974567e3ba1c01cc46c477a50cbb46c94d580c504ac53d7717135e9256d582953f1541e4f9595cc939ab6749a3c99b940a8173f7c6b3f3be6b9cb6e86b6e7ca0c9a45a5cf78114bcfa9516ac57dc33b7b653768d136f748c00d4427e566c4a2d388c13619248f26309749dc476c449747ec653c6cb994c45b2a574a3cd5db9cd315ab445ac84e9941ef0990c37b699c34b38d6cc4dc954619c6563d98b56531abe837a26d7109989bc60c38447cb6a5656d9ad26d721edea502e9c5a0f4971236bc7da992c9858201990a13eb27ae315ad3d851aa27473be29c684882a47e32fbef43efc787480c592fa8891affc776fd45a43348ebb505dc7482f03dc5810a13170622a79d39498c554cbca0a22331c7b457a751739f1c3146f329d694ba0b37411add8a1818b30f9d76fb4b7a6e9547a962a8abf5a52a4b305c10647b39109fed3435d5132ba55701ef4949fea310bd6acb0304ce96410d5e4351716495b42a4bed0bf13dc0b94d681582379bf40acaa38c90faaa1d54844b4ac212d38837bc5493754b8e83a03475b6957a0a540390e49d0aaf71b6d88282adcd7625f1a20f8db568d54cc67e251bb776d2e75c7ecd7976238a059d95d6e308dcafc023170b45423b402718c2bec9cccd5f35a8a831c4ced56d7fce5a78eb7e1883a14d4f2f0429cce8b0098b97b42", + "keypair_secret_key_hex": "7bb8136e0784d39448aab670fd4998cdd41670c6c04f3c2263920a80f08fde00b86267cb1e232aff0aa5af4199ec3b66af2b35dda5aba2ec9806864a654274fa5b83ecc64af4064fd1d4266cab2666e7ce0d713649da7c4db42378478707955c66d7a2de314f210242524b9bec46a1b238960cc4c341762b1ffb24ed10558028c2fb1683b21315fa8a55ed87520ba38acd71501a68c579cc48a0903cd710b989e83a3a029981914fb5fcc0e3b0426a9b13936b714215b13503084db127be7c24b9254bf0372a58351f276ab15aea424a6ba34ed86c6ee4c4838bc26e3948bf221f1f8a93b8b3acc41c28cde77ce2b075527460bc355f57e9ca0a048be4d108aea55ec7675abba90498c1293d90b8422a9f0528394697708c59bb39acafc333045de29a03667f395c1b12c10151aabc560863aca56b59a22427d700081a6f5bb268b6466f1b7c46ea2813890791f5b7464b60a6561b0c4d07495924c94503bc8f02c444b98c163a2bc19c932584b759b85d0efab10306c3cbcb5f7d552c2b036f89574bf3c5896ce73f79875632ab91189b69c448b0f95c648c05c33b0b0f8ec085af280461571a5bc78a0fc865fc71815fb77f2658580985a32dbab6b8e36cc9412ed36b623b29a8ffb69bc93bacd1b5a7859297598102be3ba18062b167c5bdd457517885a1bfe3b6c4418a9155be32c93664b0937b598ab1b0005b45151762ac9932c8796bb987663b2872217c68cbf205abd7063e2a7727ae21914c7797804558e8d0a16740874a837a8b651cecf254b4c62face44d660a601b4b3135f19c38e71506ca3d10416d15f9213e12b83e481cebbb188b44b86753211e443d5d3c8c6120c4168a7168d181c3a59bbc47b8332b04ecd3bd7f231a7f69aa5623cc0435c4fc3b16f59814c3960ce5ec3b78865303e5b432796b5267318d2497e1b08c9bfc4248d79a4439c587d248333b1273a10c5344a372ecc247a894926bc1e9f96bdd3c5046a62abb269d8a80853d220c75ab1603d96e5e42b1dc8b9ca0e72df2722d09417a3ba2c5537a19d4836b3df50bbb3836720a6b48a640a2d3bc2e0c57f3f6ccbc528e16923e4ad15041936edf60ab8cbaa8684c1024339b8330903edbb720bc368fc32593493c6f5bc6f5222a0138b90c70526ecb3c57798c59647dfd7a020e442196441ac5cbac4fd857ec5b3bc35a1b37315abc02570dab75033b996d2a36f806525c8b94b08077e711b6ab8a88720aba5d839ebd4ac777cba2695c2f80703c31b1a52e72b882866b46533abbf93c76d19f9e8ca845f9a2c2580054b1ba61d82cf08421492b719e51a482f028fac7063fd7c3e093ad495891e3e1200d227eb17602b973301fc0aab3da063be4169f5166eb0347b409b647e3b72d92907d2910feb030824c4cd298b13d6a5b90146c3a322c4638ce83b5561603b2610802445c073bb62467d501329776fbc32e4ea787dbca5a59a2424e87b1f0d10986e5caeef831b8b122a6213d68964c765742473020b6685ca2f556ac541956ec057649cd9c934cbaa3aab6252e7369c08a96a58ab14667b670a2f974a4a81250a94896188ce2910ea4550ba0b877e9d8439b1514ab4291580b7fc5f297c1f667ff6727ba83680b477a775471b785969ef12596b5abb327910839b915fb290259be6be7342312618cac4042567dca209a5c10256098912cb20f269964ca2bc70ba37df9f53f3a1377cb1118d37000ae578ffd006dd0a69ed7035706e5bcd2833aab85238de041a2583156d42e0b8335702922a431929c11780a0149deb2595dbb9370f58e671c355951c8aec45c3ba42cc3c213cf38afaffbbfcb76c8e9095888148a9570a4ad1b82ea4c7c1c5485e58ac5682758324a6df610c31c1c2fc2a6630db0a36febca23595be3c344247276ee7c842647a4c345ac3e82672c19ae41a70ab71155d0a2464992231a1c1d5f4a4334a45e96f755e15a8a6e8a9346a0c03e0352526b75f4984c6d38c16fa967ddebbfbe8a77ac0437618b2b50a2900a188fdbac3730e41c76a9326aec563f62cc3fb855d2551a8f6b688dbc76c7dc1da1506bb063c63a89c9164a36c020a0262089cad993c9e3712f471bc2e856dcdb30fa8a6fac202d976abf8e8282f6ab6cea284b0542bd09543feac76dbf8864ca6433395a43d2716a696b2c24b07e5cd8c90f7302d2f890545310a30c9557a9c19f4a024cbcae780c9b9f80a4037aa7b8526776b64f68224379fb8d15ac4f53297e7768938be838456ba62d4168313162df1c7a51375a5299110f8c951116925ed8a6b1f2891df7476b6a07fceabe6a7481dba1542ee134c706aa2c4b851cc893c3a2541bc336b31452c068c1127cb808d465dac26421869df8599bab1633f9d2b0569acba7843686014e7c621e2960be20a0871b76111fc0c97d601743e23cb189b319f96057fcb1dc7290d0d4bf58735bd196c8f4a75422ca2375027996d26ab4a24df0e9560c1193e301bd93b77ad4a12aab86aefcd11f094b7ebd2833c1528a350cc10cd377dca0781cf1bc072650b916a1f07b4f96a72779ebac2e081697956203c55f740095aba5c1dbfa018d43251245390651ca929148fc195c0c1169a2b947af09cceb0c13a775413b59b3f7b292469c155d49a9ed71b6b7d605c9963f78984342413382974512741bc32ab63f9b443b4bb10c4a89f44747e86b3bb009b4f1a84cfea1718cfc89bd876438376f9d98c0c259852b160cbd4804cad47ba44b989af1576730905bb72546834c232177ff6b7a0c7444589cb12ad6cfb41bc1ee17694b9b363f439e3f3a82f403cd3ff8300ad668cfe219e2782b83c0634d61594b970f55f2c1cacbc42aa1021edcaba002c1ac1ac152880701c04134c1b8046951a48ac55886aae60a050ff2a3b5344e29952ea5d08a2af0afe3842fd968436eea4fe2fb521122a2ee06b6ab87bbd4a4cae2d7423658093f2a12c71b11969188d2e298b567bc55318cd3202ce69216570507b3506d59bc6ad015589e1a154d93651788c91e91035582280ada89e6271aee532102563194a622b21ac8c809247d32318f82b3ff812e72e3365a20740ee57ea2135274050f108c59e2066bd782580d501d75655de709a4bfc201b82230a5c31d8332331a8c1ef1f28b29e5ca67c5a1a99521eaea3d941a2fc3f437928149cf8c37fc77918e91b152a1ad10372eeafc8c6dc27d0ec40154c72c96ba98e9aa8b82d9c0f05b55888b0bd0934dd2f730e7a26148c70cecbb00b50177577c347c89cd151c7397e3009ae28144e19bda08c5fd85996402467b994f5d651c3734a617a605f32a76b1b035eec72acad48c3b5c8b41f662531a5ce0b37b38d1253cb98de3c41107b1ca31a91fc89bc0c21602423bb064649fd2bb9fe7d72f79229be92596032c9ea36c7de754aae52004490105130497b0e42344cb8342f0ac09e20b165c2846c52e75b17ac4f84fc5f41dcc454225f9b010e95e75b44318bc9996763ed5e020d4a654500b071c8a1df575c1fb173bcbbcc2d1527fd0e852a699c11414557b190a965ac092e1a24b001c01d72fda0a85bec0703f85254f998bc399cb57a48696d753e0490597482d64529e8dc585804164a3dc048585ce02d9596dc80fe58396a8922232c91682297daa53bb2545c5e949b532163d8fa63b77c51dec7c3bd1b70c83974567e3ba1c01cc46c477a50cbb46c94d580c504ac53d7717135e9256d582953f1541e4f9595cc939ab6749a3c99b940a8173f7c6b3f3be6b9cb6e86b6e7ca0c9a45a5cf78114bcfa9516ac57dc33b7b653768d136f748c00d4427e566c4a2d388c13619248f26309749dc476c449747ec653c6cb994c45b2a574a3cd5db9cd315ab445ac84e9941ef0990c37b699c34b38d6cc4dc954619c6563d98b56531abe837a26d7109989bc60c38447cb6a5656d9ad26d721edea502e9c5a0f4971236bc7da992c9858201990a13eb27ae315ad3d851aa27473be29c684882a47e32fbef43efc787480c592fa8891affc776fd45a43348ebb505dc7482f03dc5810a13170622a79d39498c554cbca0a22331c7b457a751739f1c3146f329d694ba0b37411add8a1818b30f9d76fb4b7a6e9547a962a8abf5a52a4b305c10647b39109fed3435d5132ba55701ef4949fea310bd6acb0304ce96410d5e4351716495b42a4bed0bf13dc0b94d681582379bf40acaa38c90faaa1d54844b4ac212d38837bc5493754b8e83a03475b6957a0a540390e49d0aaf71b6d88282adcd7625f1a20f8db568d54cc67e251bb776d2e75c7ecd7976238a059d95d6e308dcafc023170b45423b402718c2bec9cccd5f35a8a831c4ced56d7fce5a78eb7e1883a14d4f2f0429cce8b0098b97b420a5ca7f9fc330989c894ee2c3dcc837190112b74a2ec51551afb5c2fe512b1a3606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f", + "ciphertext_hex": "b6ceb79b27c81145a47e2b98f26c9873f1e86f1642666f6bc3a3cbad390f1fb5bbb111d815cfb2337c7c2504d8ea0bff8ff117e0ee2d73fe390602739064ddfbd5a57740b3bab2a22429a107d631a74eb7e7ee86f01b11e1d145ca5f163ef1be85de20f3a4be1d18dc03c99190b7097bd065c028a593b09630e02a54054ffd901ea6de032cf169add68d982fd959fa90e313edbb07b5e35f61b6c0a9985c7046f024572804a3274db4346339f66b04a84b8e96f4befd25d25b47e22ad02e57708a4c72a0567d314b801140c65ff0b78f3f9ae1b45ce4df4dfbf7b617db5f77aea77aad72c674bc1d85f6c579bd2cbf31e86a5bef6d225cc4450f126d6e0131c1a2fd570f18edbc51a20fb0f39806d8573f0f9d0e4d54385e2301201303c283aec9ff145cc454bab1674591fa085dad2a98caa688da4a8aa63d6279e1c85eb90862824eaa15222a9e4595958bee142079224b733d02fab6c71be54f160dd4359e61b75e3501210e6a8f2cca2fcb4b5515219c678193bc98fac82477dc851c290fcbef4c664ee7c4dd984c2302f253af8b16dd4d61fa15971bdf28e6f4ea2c582e68c201ef0e30ab066ed26c53f7ec4d89ef2391b5884febeb4977039002a1edb727f80f93d343bf055ac0e55836435d68ccfe7a3aba42bde0ec2a4c34a20eacc1294099ef13224b02f399b4ced07f71468ba578628f2d1bc23fa60cebc0092dd46119a7f7f39a5e75a8359912b94438622e845a6fe2998e62a9e8debe3574891291e3ce1a28da0bc6a46f778bb653a3a220fdea6ad5ee95715ecbde0961f99532c026c17ffa0ea3cb9b2d52aac22ee5ec58ef320c25abc011ac724c5d262b0bbef36cdb33280ac55d0680c0a78f718710c2aaaa33ed1e1f4b3abfcc257e348a634053b4460f902ae2c48d5218687234a1d032c8c54c8b4a5727279fe806de988ad0eda8a25f463a8261d5a1dbe43bdc9de21ae18279d008a27e0ca1bb7ea1744c4016cdf561930e5eab3cfbf58487fc6854b266b84474efffd6f0105509e588529158698d00f75c10816cbaa46abbfdc2c34fe721ef6179751cfa405980eb4355d9c14c7202492c00c0be61c4fae6e45127ea028b28c9981bdb496889c4478af4ffa957fce34bfd66d9761f0e03bcf7c0e07e9f7821d81d7ea147aad3de6217739340b35b8409aa7f4715d9301c138b5989795eda449ae1b1c32418b611e91e701c78660f3970af26601f49435ee16f664bcdee039eb4aab46c515ed6528237ed0134253760b91bdc950b76ca6338a94b100f33a0f2ed7df6c0a61a9f5f5218816f6eb973c58a9336eeb1b6c74607b6f4d73407a78d7eea47d91c1789f3f5b9bd49fc7ab4b9db312378aee74d0c0806a93e320710fad7e8a80c5929f2a4a0ab830595a8be4cbf8bc5f69b7f4a4d20c5211db6c0b0c4b7ab20165511ebdb1abeafa815078e8531db47d58b59f85b3e56b9cdb05ebba65b7ac45cecd792f3b2c4e8893d3f4dd3eb394dec7a2c4b85212589a48a641b01c50d2e69d322ab0e15e2583c209182671c70355c5c3dc5e861f92f2d7d231c73ee0917620ceae32e20e57722fdf8b4aa8c0c1b0c93a9d084dab1f083a9a14b9b4f0acb175e83dff0edfa88162cd26ed706c8472333933d890ef0120652209e76f179f45016465b8e09771a539f723dc7b90ea3cd58155ad763ec0befdbc4d6c9a9821a5fbd74fab438b23bc8a38f416bd69dc127a3f19789c742585ab49f064be1fab21735bff9076a9de3b74353337e7bd80ff42601a30af88ecdb16b0476be2ac22105887c8d70be57da1df9f23700e2289bac23128a76db3ea9c6ab4026054580b524b5b100951410f5a4ef15d1a6597dea11a66e4685bcf789dbb369defe869ad4b1828b5e65a22683f87d86d9f45d52c20c6a04e0d196dc32b1e4f22be046bfd3033b27f3ea056acce52590e3fbaabea6c71764ebd105c26659c8ccbaa26c1ae77f1a77b4a4e481e1c841c7233790cafe3a59a66a344da8e69fefbcb855aa531ff22923e06565b9787560a69503e3eef10a049a758295d1258eefe28f3dff10530159bd60e24004321130d6d06bae9dc1515d1be21d36b32e47e8378b7af3238698a0face37fb103f57f2929f7e76422f6770ec5c9370f11a49e74b3a6c7eacded55e169a83907dc6dbe3130960714b09d6d533ccaf02ab6329c22ad85eb50cff2fc6e340497b108b", + "shared_secret_encaps_hex": "68d2a649aec8a8a4689a9a649397f633a05066a94cabc570a58b848f77450883", + "shared_secret_decaps_hex": "68d2a649aec8a8a4689a9a649397f633a05066a94cabc570a58b848f77450883" + } + ] +} diff --git a/tests/vectors/manifest.json b/tests/vectors/manifest.json new file mode 100644 index 0000000..8ef5e45 --- /dev/null +++ b/tests/vectors/manifest.json @@ -0,0 +1,48 @@ +{ + "acvp": { + "repository": "usnistgov/ACVP-Server", + "commit": "975de31eb83d87039ec88934fdc47d8c312b892d", + "directory": "gen-val/json-files", + "files": { + "ML-KEM-keyGen-FIPS203/internalProjection.json": "d7a62a2c3476957f56dd8d24f9004ea6776ccfe995ffe71a65bb9506dc9c7b1b", + "ML-KEM-encapDecap-FIPS203/internalProjection.json": "a556952ce869bb89c3a3196a701dad89647c193a34c86eafb61a9d710d5b810f", + "ML-KEM-encapDecap-FIPS203-tr1/internalProjection.json": "5e88fbd351dc2915b2ab399f5dffd269e1cdbdd0186103ab0e90b374cf592187" + } + }, + "cctv": { + "repository": "C2SP/CCTV", + "commit": "50a8ecf2a220f4c8bdc4f085789b8e85c26829e7", + "directory": "ML-KEM", + "files": { + "README.md": "97c8e10fe83d4512edf8d37d8344cdc4a6dde321bac22a098b2ef3793cc3dd4d", + "modulus/ML-KEM-512.txt.gz": "4292314fd44bb211a27ef38273dcac32f602601c2f65b15a69773221227bf298", + "modulus/ML-KEM-768.txt.gz": "22308df988866e2731c9b79bfa0ce754ea962a77b198436d56bde6481a526af7", + "modulus/ML-KEM-1024.txt.gz": "5792e788280ecdf5be7bfd2d8b66e18a0f9b9c80f4beb1d801fd27d20e882bf7", + "strcmp/ML-KEM-512.txt": "4bc308bf9a7ac41c0e1c6845e9195d4932f66650e09a4ff76ed780e5f606a42f", + "strcmp/ML-KEM-768.txt": "2b09aa46d8b1c7b9a549a25f009cbd3937b98df177e868d3eb6395930079e4fa", + "strcmp/ML-KEM-1024.txt": "5059fcb4d5912c37bef560e92608799b576cf785bc20708688f65ffcfca4adfb", + "unluckysample/ML-KEM-512.txt": "9d4e0ae011978a8f88a49b755ea6996a693428aaea0477c84331580c15b77a9b", + "unluckysample/ML-KEM-768.txt": "fede6f787581dc708e5c3337d7e49de33333d23023470668cd0588b65ad0ba90", + "unluckysample/ML-KEM-1024.txt": "8da6cdb2fb0d4e2223dfa5229ec97d4ded7b93bd8b2202e9a7533850c817b8cd" + } + }, + "wycheproof": { + "repository": "C2SP/wycheproof", + "commit": "d17cc89fa5c8f4f4d378b5385ebf75da5d0926c9", + "directory": "testvectors_v1", + "files": { + "mlkem_512_test.json": "18bc5455d5bf8226b3ab1d1deb51f3ed7c44b3d90039eb25416d40fa77e76f20", + "mlkem_512_encaps_test.json": "85a69664f2e8243f5085f01fb22f9635b100b16a8935cf2b2ac94c127511a20c", + "mlkem_512_keygen_seed_test.json": "877ae6f5550d0e802086e5812bdbd23c16afa31cd3bff9669cd9661d3fbf2d85", + "mlkem_512_semi_expanded_decaps_test.json": "bb90c7997dc3695e52882608b7c79675a012c031dd50dc08e76c4775a762ad14", + "mlkem_768_test.json": "c59c067ae794c343df575dd90f6f7458f51881b11a22d6e9d8677c8d9ee21e90", + "mlkem_768_encaps_test.json": "9d4381f94c40853bba430245b94968b7390d9175aacd9f1ae4e250a71c78b713", + "mlkem_768_keygen_seed_test.json": "fde5abe284396f4cb3c4610b90d680f0b57782e94c3365c97aee59e24881ebe4", + "mlkem_768_semi_expanded_decaps_test.json": "e4438ab7d4dd7b6ace7165e45aeed4403082f981f86300c8369f69d3d071060a", + "mlkem_1024_test.json": "17c5b764d78c05522f1980fcb41d82add573f11de5d13004ae0b83bf46d9c43a", + "mlkem_1024_encaps_test.json": "da41e8daf57e40a6b334a722e3f56067817352f5583fdb2434da1a2cd611358e", + "mlkem_1024_keygen_seed_test.json": "cd9241bf5d65a78e005866ea2c660615c17f50caa9afc2b96fd1573cc65617b5", + "mlkem_1024_semi_expanded_decaps_test.json": "a4a7c88152df3d8d4b3f33aad584167dfaff67195cfde08aac4b981030b4d05c" + } + } +}