diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fae603..de269b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,16 @@ ## Unreleased +## 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 `commits` job re-runs them on every commit of a PR via `git rebase --exec scripts/check.sh`. `requirements-dev.txt` declares maturin and pytest; wheel release now waits for `test`. - 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 diff --git a/Cargo.lock b/Cargo.lock index 17a0710..efdc975 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -275,7 +275,7 @@ dependencies = [ [[package]] name = "pykyber" -version = "1.0.5" +version = "2.0.0" dependencies = [ "flate2", "ml-kem", diff --git a/Cargo.toml b/Cargo.toml index b908696..a3fbba8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pykyber" -version = "1.0.5" +version = "2.0.0" edition = "2021" [lib] diff --git a/README.md b/README.md index 1158e5f..2464604 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # PyKyber -A Python library for Kyber post-quantum key encapsulation, implemented in Rust. +A Python library for post-quantum key encapsulation, implemented in Rust: ML-KEM (FIPS 203), plus the legacy round-3 CRYSTALS-Kyber classes, which are deprecated. + +ML-KEM and round-3 Kyber are not interoperable. If you are moving from Kyber, read the [migration guide](docs/migration.md) first. ## Installation @@ -14,10 +16,10 @@ pip install pykyber import pykyber # Generate a keypair (Alice) -alice_keypair = pykyber.Kyber768() +alice_keypair = pykyber.ML_KEM_768() # Encapsulate - create shared secret (Bob) -bob_result = pykyber.Kyber768.encapsulate(alice_keypair.public_key) +bob_result = pykyber.ML_KEM_768.encapsulate(alice_keypair.public_key) # Decapsulate - recover shared secret (Alice) shared_secret = alice_keypair.decapsulate(bob_result.ciphertext) @@ -26,19 +28,22 @@ shared_secret = alice_keypair.decapsulate(bob_result.ciphertext) print(f"Match: {bob_result.shared_secret == shared_secret}") ``` +ML-KEM-768 is the parameter set NIST recommends as the default (FIPS 203, section 8). + ## API Usage ```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 +keypair = pykyber.ML_KEM_512() # security category 1 +keypair = pykyber.ML_KEM_768() # security category 3 (NIST default) +keypair = pykyber.ML_KEM_1024() # security category 5 -# Deterministic key generation from a 64-byte seed +# Deterministic key generation from a 64-byte seed d || z (the seed is secret) seed = bytes(range(64)) -keypair = pykyber.Kyber768(seed=seed) +keypair = pykyber.ML_KEM_768(seed=seed) +assert keypair.seed == seed # None for randomly generated keypairs # Access raw key bytes public_key = keypair.public_key # bytes @@ -70,49 +75,59 @@ 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) +result = pykyber.ML_KEM_768.encapsulate(public_key) # Decapsulate with just ciphertext and secret key -shared_secret = pykyber.Kyber768.decapsulate(ciphertext, secret_key) +shared_secret = pykyber.ML_KEM_768.decapsulate(result.ciphertext, secret_key) # Load a Keypair object from existing keys -keypair = pykyber.Kyber768.from_keys(public_key, secret_key) +keypair = pykyber.ML_KEM_768.from_keys(public_key, secret_key) ``` ## 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 | +| Variant | Public Key | Secret Key | Ciphertext | Shared Secret | +|-------------|------------|------------|------------|---------------| +| ML-KEM-512 | 800 bytes | 1632 bytes | 768 bytes | 32 bytes | +| ML-KEM-768 | 1184 bytes | 2400 bytes | 1088 bytes | 32 bytes | +| ML-KEM-1024 | 1568 bytes | 3168 bytes | 1568 bytes | 32 bytes | +The legacy Kyber classes use the same sizes, so a key or ciphertext cannot be identified as Kyber or ML-KEM from its length. ## Error Handling -All operations raise `KyberError` when invalid input is provided: +All operations raise `KyberError`, or one of its subclasses, when invalid input is provided: ```python import pykyber try: - pykyber.Kyber768.encapsulate(b"too_short") + pykyber.ML_KEM_768.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). +# Output: Invalid public_key for ML-KEM-768.encapsulate: expected 1184 bytes, got 9. ``` 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 +- Invalid key, ciphertext or seed size: `InvalidInputError` +- Encapsulation key that fails the FIPS 203 section 7.2 check: `InvalidEncapsulationKeyError` +- Decapsulation key that fails the FIPS 203 section 7.3 check: `InvalidDecapsulationKeyError` +- An object of another algorithm: (a `Kyber*` keypair passed to an `ML_KEM_*` method, or another ML-KEM parameter set): `AlgorithmMismatchError` + +A well-formed ciphertext that was not made for the key does not raise; decapsulation returns a different, pseudorandom secret (implicit rejection, FIPS 203). + +## Deprecated: Kyber + +`Kyber512`, `Kyber768` and `Kyber1024` implement round-3 CRYSTALS-Kyber. They still work and their output has not changed from 1.0.5, but they are deprecated in favor of ML-KEM: the first use of one in a process emits a `KyberDeprecationWarning` (a `DeprecationWarning`, which Python hides by default unless it is raised from `__main__`; run with `-W default::DeprecationWarning` to see it). The [migration guide](docs/migration.md) explains how to move. + +## Security status + +The ML-KEM code is checked against the official ACVP, C2SP CCTV and Wycheproof test vectors and against an independent implementation (a dev-only differential test). It has not been independently audited, and the side-channel review planned for 2.0 has not been completed, so this README makes no constant-time claim. Report vulnerabilities as described in [SECURITY.md](SECURITY.md). Secret keys and shared secrets are returned as immutable Python `bytes` that cannot be wiped from memory. ## Performance -Performance benchmarks (100 iterations each): +Performance benchmarks of the legacy Kyber classes (100 iterations each; the benchmark has not been rerun for ML-KEM): | Variant | Keypair | Encapsulate | Decapsulate | |---------|---------|-------------|-------------| 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/index.md b/docs/index.md index 945c3a8..7789966 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,9 +1,11 @@ # 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) +- [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](../LICENSE): public domain (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/pyproject.toml b/pyproject.toml index 2cc3c87..fcd65a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ 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"} @@ -28,7 +28,7 @@ classifiers = [ [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"