Metadata-Version: 2.4
Name: enciphers
Version: 2.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 :: 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.

> **Version 2.0 note**: rewritten around standard AEAD ciphers
> (AES-256-GCM / XChaCha20-Poly1305), matching the underlying `encipher`
> crate's own 2.0 release. This replaces the previous custom
> substitution+HMAC design entirely — a clean break, not an incremental
> update. See [CHANGELOG.md](CHANGELOG.md) and "Upgrading from 0.x"
> below for exactly what broke and why.

## Benchmark

> Measured with `benchmark.py` in this repo, 1000 iterations, `Backend.AES256_GCM`.
> Fernet and itsdangerous are pure Python; rfernet is Rust-backed (Fernet algorithm).
> Numbers are hardware-dependent — re-run `benchmark.py` yourself to reproduce.

| | enciphers | Fernet | rfernet | itsdangerous |
|---|---|---|---|---|
| encrypt (ms) | **0.33ms** | 6.57ms | 2.79ms | 11.75ms |
| decrypt (ms) | **0.53ms** | 5.68ms | 1.81ms | 9.72ms |
| memory enc (B) | **195** | 1282 | 225 | 383,391 |
| memory dec (B) | **99** | 1219 | 99 | 59,594 |
| encrypts data | ✅ | ✅ | ✅ | ❌ signs only |
| algorithm | AES-256-GCM / XChaCha20-Poly1305 | AES-128-CBC | AES-128-CBC | HMAC |

## 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

```bash
pip install enciphers
```

## Usage

```python
import orjson
from enciphers import Encipher, Backend

cipher = Encipher(Backend.AES256_GCM, key=YOUR_RANDOM_128_BIT_KEY)
# or from an environment variable
cipher = Encipher(Backend.AES256_GCM, key_env="CIPHER_KEY")

token = cipher.encrypt(orjson.dumps({"id": "1", "name": "mejlad"}))
data  = orjson.loads(cipher.decrypt(token))
```

`YOUR_RANDOM_128_BIT_KEY` must be a genuinely random 128-bit value,
generated once with a real CSPRNG (e.g. `secrets.randbits(128)`) and
stored like any other secret.

## Choosing a backend

```python
Backend.AES256_GCM          # fastest on any CPU with AES instruction support
Backend.XCHACHA20_POLY1305  # fastest without it, and a fine choice anywhere
```

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(orjson.dumps(payload), purpose="password-reset")

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

# decrypt() only ever accepts the default purpose, "session":
cipher.decrypt(reset_token)  # raises ValueError — wrong purpose
```

## Expiry

```python
import time

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

## Revocation

There is no `session_id` or similar concept in this library, on
purpose. A token is already unique — its nonce guarantees that — so
it's already a fine key to store in a revocation list of your own the
moment a caller logs out (the full token string, or a hash of it).
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.

## Parameters

| Parameter | Type | Description |
|---|---|---|
| `backend` | `Backend` | `Backend.AES256_GCM` or `Backend.XCHACHA20_POLY1305` (required) |
| `key` | `int` | Secret key, a random 128-bit value |
| `key_env` | `str` | Environment variable name holding the key |

> Exactly one of `key` or `key_env` must be provided — passing both
> raises an error.

## Upgrading from 0.x

Tokens minted by any earlier release cannot be read by 2.0, and vice
versa — the underlying algorithm changed, not just the token format.
There's no compatibility shim, by design. In practice this only matters
for tokens with a lifetime that could still be active at your
deployment moment — session cookies are typically short-lived enough
that this is a non-issue, since old tokens simply expire on their own
and every token minted after upgrading is a 2.0 token from the start.

Every constructor and method call needs to be updated — this isn't a
drop-in version bump:

### `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.

### `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.

## License

Apache-2.0 — Copyright 2026 Mejlad Alsubaie

