Metadata-Version: 2.4
Name: enciphers
Version: 3.0.0
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Rust
Classifier: Topic :: Security :: Cryptography
Requires-Dist: orjson
License-File: LICENSE
Summary: Fast encryption library with Rust-powered Python bindings
Keywords: encryption,cipher,rust,fast,security
Author: Mejlad Alsubaie
License: Apache-2.0
Requires-Python: >=3.11
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM

# enciphers

Fast Rust-powered encryption for Python.

This package is a native extension, not a pure-Python implementation:
it's built with [PyO3](https://pyo3.rs) and packaged with
[maturin](https://www.maturin.rs), and it doesn't implement any
cryptography itself — every `encrypt`/`decrypt` call is delegated
straight to the [`encipher`](https://crates.io/crates/encipher) Rust
crate, which in turn uses AES-256-GCM and XChaCha20-Poly1305 from the
[RustCrypto](https://github.com/RustCrypto) project. This package's own
contribution is the Python-facing surface — the `Encipher` class,
`Backend` enum, and the `bytes` in / `bytes` out convention that
matches how `orjson` is typically used alongside it.

> **3.0.0 is a breaking release**, matching Rust `encipher` 3.0.0.
> `encrypt(..., expires_at=0)` now raises `ValueError`; pass `None`
> for no expiry. Oversized purposes have a new error message. The
> release also fixes nonce reuse after `fork` and reduces encryption
> allocations. **2.x tokens remain compatible** with the same key and
> backend. Read [Upgrading from 2.x](#upgrading-from-2x) before updating
> dependent packages; see [CHANGELOG.md](CHANGELOG.md) for release details.

## Features

- **Standard AEAD** — AES-256-GCM and XChaCha20-Poly1305, both via the
  well-established [RustCrypto](https://github.com/RustCrypto)
  implementations, not a bespoke algorithm.
- **Purpose binding** — a token minted for one purpose (e.g.
  `"password-reset"`) can never be mistaken for another (e.g. a
  session), even under the same key.
- **Optional expiry**, checked only after a token's authenticity has
  already been verified, so a tampered token never surfaces as merely
  "expired."
- **Simple API** — `encrypt`, `decrypt`, `decrypt_for`.

## Installation

Requires Python 3.11 or newer. The distribution and import name are
both `enciphers`; the underlying Rust crate is named `encipher`.

```bash
python -m pip install "enciphers>=3,<4"
```

If a compatible wheel is unavailable, installing from source also
requires a Rust toolchain. See [Development](#development) to build
the current checkout locally.

## Usage

```python
import secrets

import orjson
from enciphers import Encipher, Backend

key = secrets.randbits(128)  # One-off demo key; persist a key for real deployments.
cipher = Encipher(Backend.AES256_GCM, key=key)

payload = orjson.dumps({"id": "1", "name": "mejlad"})  # bytes
token = cipher.encrypt(payload)                          # str
decoded = cipher.decrypt(token)                          # bytes
assert orjson.loads(decoded) == {"id": "1", "name": "mejlad"}
```

Generate a random 128-bit key once and store it securely. Reuse it
across restarts and workers that must read the same tokens; generating
a new key at every startup prevents reading tokens from earlier runs.
The following examples reuse `cipher` and the UTF-8 `bytes` in `payload`.

### Using an environment variable

Configure `CIPHER_KEY` with the stored key as a decimal integer string,
then construct the cipher with the variable's **name**:

```python
env_cipher = Encipher(Backend.AES256_GCM, key_env="CIPHER_KEY")
```

Pass exactly one of `key` or `key_env`. A missing or invalid environment
value, both key sources, or neither key source raises `ValueError`.

## Choosing a backend

```python
Backend.AES256_GCM
Backend.XCHACHA20_POLY1305
```

There is no "auto" option. A token minted under one backend can't be
decrypted under the other, and nothing in the token says which one
produced it. If your deployment isn't a single process on a single
machine, pick one backend and set it everywhere — don't let each
process decide on its own.

## Purpose binding

```python
reset_token = cipher.encrypt(payload, purpose="password-reset")

# Reading it back requires stating the same purpose explicitly:
assert cipher.decrypt_for(reset_token, "password-reset") == payload

# decrypt() only ever accepts the default purpose, "session":
try:
    cipher.decrypt(reset_token)
except ValueError:
    pass  # Expected: a password-reset token is not a session token.
```

An explicit purpose must contain 1 to 255 **UTF-8 bytes**, not characters.
An empty or oversized purpose raises `ValueError`; omit it or pass
`None` to use `"session"`.

## Expiry

```python
import time

token = cipher.encrypt(payload, expires_at=int(time.time()) + 3600)
non_expiring_token = cipher.encrypt(payload, expires_at=None)
```

`expires_at` is a Unix timestamp in seconds. Decryption rejects a token
at or after that timestamp. Pass `None` (or omit the argument) for no
expiry. Starting in 3.0, `expires_at=0` raises `ValueError`.

Expiry and purpose are authenticated but visible in the token's
metadata. Decryption verifies authenticity before checking purpose
and expiry. An expired token raises `ValueError`; supplying a positive
timestamp already in the past is allowed when encrypting, but the
resulting token is immediately expired.

## API reference

The Python API consists of `Backend`, `Encipher`, and these three
instance methods. Arguments can be passed by position or by keyword.

| Call | Returns | Behavior |
|---|---|---|
| `Encipher(backend, key=None, key_env=None)` | `Encipher` | Fixes the key and backend for this instance |
| `cipher.encrypt(data, expires_at=None, purpose=None)` | `str` | Encrypts UTF-8 bytes and returns a token |
| `cipher.decrypt(token)` | `bytes` | Accepts only tokens for `"session"` |
| `cipher.decrypt_for(token, purpose)` | `bytes` | Requires an exact match with the supplied purpose, including `"session"` |

### Parameters and limits

| Parameter | Type | Accepted values |
|---|---|---|
| `backend` | `Backend` | `Backend.AES256_GCM` or `Backend.XCHACHA20_POLY1305`; required |
| `key` | `int` or `None` | Integer from `0` to `2**128 - 1`; generate a random 128-bit key |
| `key_env` | `str` or `None` | Name of an environment variable containing the key as a decimal integer |
| `data` | `bytes` | Valid UTF-8, at most 16,384 bytes; empty payloads are allowed |
| `expires_at` | `int` or `None` | Unix timestamp in seconds from `1` to `2**64 - 1`; `None` means no expiry |
| `purpose` on encryption | `str` or `None` | 1–255 UTF-8 bytes; `None` uses `"session"` |
| `purpose` on decryption | `str` | Expected purpose; required for `decrypt_for` |
| `token` | `str` | Token returned by `encrypt`; input over 32,768 UTF-8 bytes is rejected |

Use `orjson.dumps(data)` or `text.encode("utf-8")` before encrypting.
The binding does not accept a Python `str` as plaintext or arbitrary
binary data that is not valid UTF-8.

### Exceptions

| Exception | Conditions |
|---|---|
| `ValueError` | Invalid key configuration, invalid UTF-8, oversized payload or purpose, empty purpose, or `expires_at=0` |
| `ValueError` | Invalid, oversized, tampered, expired, wrong-key, wrong-backend, or wrong-purpose token |
| `ValueError` | Operating system randomness is unavailable during encryption |
| `OverflowError` | A direct `key` exceeds `2**128 - 1`, or `expires_at` is negative or exceeds `2**64 - 1` |
| `ValueError` or `OverflowError` | A direct `key` is negative; PyO3's exception depends on the Python interpreter |
| `TypeError` | An argument has an incompatible Python type |

All Rust `EncipherError` variants are mapped to Python `ValueError`;
the binding does not expose separate exception classes for them. Error
messages come from Rust and can change between releases. A key outside
the valid range in an environment variable raises `ValueError`, since
the Rust constructor parses that string. For a negative direct key,
the CPython wheels raise `OverflowError` on 3.11/3.12 and `ValueError`
on 3.13/3.14.

## Revocation

There is no `session_id` or similar concept in this library, on
purpose. Random nonces make token collisions unlikely, but do not
guarantee uniqueness. Store the full token string, or a hash of it, in
a revocation list of your own when a caller logs out.
Where that list lives (an in-memory cache, Redis, a database) is a
deployment decision this library deliberately has no opinion on.

## Trust and fuzzing

This library is a thin binding — all cryptographic work happens in the
underlying `encipher` Rust crate, not in Python code. That crate's
`decrypt`/`decrypt_for` path and its token-parsing logic are
fuzz-tested with `cargo-fuzz`; see the
[`encipher` repository](https://github.com/mjlad/encipher) if you want
to run that yourself or read about what's covered.

## Upgrading from 2.x

### Breaking changes in 3.0.0

| Behavior | 2.x | 3.0.0 |
|---|---|---|
| Encrypt with `expires_at=0` | Creates a non-expiring token | Raises `ValueError`; use `None` |
| Encrypt with a purpose over 255 UTF-8 bytes | Raises `ValueError` with a plaintext-size message | Raises `ValueError` with `purpose exceeds the 255-byte limit` |

This behavior change is why the Python package moves from `2.0.0` to
`3.0.0`. The constructor, enum values, method signatures, and return
types are unchanged. Applications that catch `ValueError` can keep
doing so; update any assertions or branches that match the old
oversized-purpose message.

For code that previously used `cipher.encrypt(payload, expires_at=0)`,
the 3.0 equivalent is:

```python
token = cipher.encrypt(payload, expires_at=None)
# Omitting expires_at has the same non-expiring behavior:
token = cipher.encrypt(payload)
```

### Updating dependent packages

1. Update the dependency requirement to `enciphers>=3,<4`. For example,
   change the existing entry in your package's `pyproject.toml`:

   ```toml
   [project]
   dependencies = ["enciphers>=3,<4"]
   ```

   Preserve your other dependencies and regenerate your dependency
   manager's lockfile if the project uses one.
2. Replace explicit zero expiries, including wrapper defaults and
   positional calls such as `cipher.encrypt(payload, 0)`, with `None`.
   Keep positive timestamps unchanged. Update error-message assertions
   as described above.
3. Run the dependent package's token, session, expiry, and purpose
   tests, then upgrade all workers to pick up the nonce-generation fix.
   If your public API passes these behaviors through to callers,
   document the break in that package's release notes as well.

### Token compatibility and workers

The token format and key derivation are unchanged. Keep the same key
and backend to read existing 2.x tokens, including old tokens minted
with `expires_at=0`, which remain non-expiring. Tokens minted by 3.0
can also be read by 2.x. This upgrade does not require key rotation or
invalidating existing tokens.

Each nonce now comes directly from operating system randomness, so
an `Encipher` instance created before `fork` can be used by the worker
processes. Update every worker to 3.0 to pick up that fix.

## Upgrading from 0.x

Tokens minted by 0.x cannot be read by 3.x, and vice versa. The switch
to standard AEAD in 2.0 changed the algorithm and token format; 3.0
keeps that AEAD format. There is no compatibility shim. Plan to reissue
active 0.x tokens when deploying the upgrade; this includes requiring
a new login for sessions backed by those tokens.

Update constructor calls and review how the methods are used:

### `Encipher(...)`

- `step: int` is gone. Pass a `backend: Backend`
  (`Backend.AES256_GCM` or `Backend.XCHACHA20_POLY1305`) as the first
  argument instead — there's no numeric offset to choose anymore, the
  backend is a real algorithm choice.
- `key`'s valid range grew from a 64-bit to a **128-bit** integer. An
  old 0.x key is still a valid 128-bit integer (just a small one), but
  for a *new* key you should generate the full 128 bits of randomness,
  e.g. `secrets.randbits(128)` instead of `secrets.randbits(64)`.
- Passing both `key` and `key_env` together used to silently prefer
  `key`; it now raises `ValueError` instead.

### `encrypt(...)`

- Two new optional keyword arguments: `expires_at` (a Unix timestamp)
  and `purpose` (defaults to `"session"` if omitted). Existing calls
  that only pass `data` keep working unchanged.
- An oversized payload now raises `ValueError` instead of the process
  crashing.
- Use `None` for no expiry; 3.0 rejects `expires_at=0`.

### `decrypt(...)` / `decrypt_for(...)`

- `decrypt(token)` now only accepts tokens minted for the default
  purpose, `"session"`. A token minted with an explicit `purpose=` must
  be read back with the new `decrypt_for(token, purpose)` method — this
  is what actually enforces purpose binding; see "Purpose binding"
  above.

## Development

GitHub Actions builds release artifacts and runs the Python regression
suite against installed wheels on Python 3.11–3.14 across Linux, Windows,
and macOS. It also installs and tests the source distribution and runs
Rust formatting and Clippy checks. Publishing on a tag requires all of
these jobs to pass. A manual workflow run builds and checks the release
without publishing to PyPI.

Build and test the bindings in a virtual environment:

```bash
python -m venv .venv
source .venv/bin/activate  # Windows: .venv\Scripts\activate
python -m pip install "maturin>=1.0,<2.0" orjson
maturin develop --locked
python -m unittest discover -s tests -v
```

The tests cover both backends, fixed tokens produced by 2.0.0, the
Python API, and nonce generation after `fork` on supported platforms.

Build release artifacts locally with:

```bash
maturin build --locked --release --out dist
maturin sdist --out dist
```

These commands create a wheel for the current Python interpreter and
platform, plus a source distribution. They do not publish a release.
`target/`, `dist/`, and `.venv/` are ignored by Git.

## Benchmark

These are historical **2.0.0** results from `benchmark.py`, using
`Backend.AES256_GCM`. Timings are totals for 1,000 calls, not per-call
latencies. No 3.0.0 benchmark results are recorded here yet.

| | enciphers | [Fernet](https://cryptography.io/en/latest/fernet/#implementation) | rfernet | itsdangerous |
|---|---|---|---|---|
| encrypt, 1,000 calls (ms) | 0.33 | 6.57 | 2.79 | 11.75 |
| decrypt, 1,000 calls (ms) | 0.53 | 5.68 | 1.81 | 9.72 |
| traced peak, one encrypt call (B) | 195 | 1282 | 225 | 383,391 |
| traced peak, one decrypt call (B) | 99 | 1219 | 99 | 59,594 |
| encrypts data | Yes | Yes | Yes | No; signs only |
| algorithm | AES-256-GCM | AES-128-CBC with HMAC | AES-128-CBC with HMAC | HMAC |

Memory figures come from Python's
[`tracemalloc`](https://docs.python.org/3/library/tracemalloc.html);
they do not represent
total process memory or all native Rust allocations. The libraries
provide different token formats and functionality, so these results
compare the example workloads. Timings vary with hardware and package
versions. To measure the installed version in your environment:

```bash
python -m pip install cryptography itsdangerous rfernet orjson
python benchmark.py
```

## License

Apache-2.0 — Copyright 2026 Mejlad Alsubaie

