Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "pykyber"
version = "1.0.5"
version = "2.0.0"
edition = "2021"

[lib]
Expand Down
65 changes: 40 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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 |
|---------|---------|-------------|-------------|
Expand Down
125 changes: 68 additions & 57 deletions docs/api.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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

Expand All @@ -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.
10 changes: 6 additions & 4 deletions docs/index.md
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading