Skip to content
mjladPublic

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

Β 

History

13 Commits

Folders and files

Repository files navigation

enciphers

Fast Rust-powered encryption for Python.

This package is a native extension, not a pure-Python implementation: it's built with PyO3 and packaged with maturin, and it doesn't implement any cryptography itself β€” every encrypt/decrypt call is delegated straight to the encipher Rust crate, which in turn uses AES-256-GCM and XChaCha20-Poly1305 from the 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 before updating dependent packages; see CHANGELOG.md for release details.

Features

  • Standard AEAD β€” AES-256-GCM and XChaCha20-Poly1305, both via the well-established 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.

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

If a compatible wheel is unavailable, installing from source also requires a Rust toolchain. See Development to build the current checkout locally.

Usage

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:

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

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

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

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

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:

    [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:

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:

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 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; 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:

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

License

Apache-2.0 β€” Copyright 2026 Mejlad Alsubaie

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages