libffx - FF1 Format-Preserving Encryption
A pure-Python implementation of NIST SP 800-38G FF1 format-preserving encryption (FPE), with AES-128/192/256.
Format-preserving encryption encrypts data while preserving its format: a
16-digit credit card number encrypts to another 16-digit number, and a DNA
string over ACGT encrypts to another DNA string of the same length.
Installation
pip install libffx
The only dependency is cryptography (OpenSSL-backed AES). Python 3.10+.
Quick Start
from ffx import FF1
key = bytes.fromhex("2b7e151628aed2a6abf7158809cf4f3c") # 16, 24, or 32 bytes
# Numeral strings (radix 2-36, or any custom alphabet)
cipher = FF1(key, radix=10)
ciphertext = cipher.encrypt("4111111111111111", tweak=b"account-42")
plaintext = cipher.decrypt(ciphertext, tweak=b"account-42")
# Custom alphabets (any unique Unicode characters, up to 65536)
dna = FF1(key, alphabet="ACGT")
encrypted = dna.encrypt("ACGTACGTACGT")
# Integers in an arbitrary domain [0, N)
plain = FF1(key) # no alphabet needed for the integer API
y = plain.encrypt_int(123456789, domain=10**9)
x = plain.decrypt_int(y, domain=10**9)
API
FF1
FF1(key, *, radix=None, alphabet=None, allow_small_domain=False)
key:bytes(orbytearray) of exactly 16, 24, or 32 bytes (AES-128/192/256); other lengths raiseKeyLengthError.radix: 2-36; the alphabet is"0123456789abcdefghijklmnopqrstuvwxyz"[:radix].alphabet: explicit alphabet string (2-65536 unique Unicode characters). At most one ofradix/alphabetmay be given. With neither, only the integer API is available.allow_small_domain: relax the minimum domain from 1,000,000 (Draft SP 800-38G Rev 1) to 100 (original SP 800-38G).
Instances hold no per-call state and are safe to share between threads; construct one per key and reuse it.
encrypt / decrypt
encrypt(plaintext, *, tweak=b"") -> str
decrypt(ciphertext, *, tweak=b"") -> str
Encrypt or decrypt a numeral string. Output has the same length over the
same alphabet. Input is case- and character-exact (no normalization);
characters outside the alphabet raise AlphabetError. The message length
n must satisfy n >= 2 and radix**n >= 1_000_000 (or >= 100 with
allow_small_domain=True); otherwise DomainError.
Tweaks are arbitrary bytes (len < 2**32, else DomainError) acting as
public associated data: the same plaintext under different tweaks yields
unrelated ciphertexts.
encrypt_int / decrypt_int
encrypt_int(x, *, domain, tweak=b"") -> int
decrypt_int(y, *, domain, tweak=b"") -> int
Encrypt an integer 0 <= x < domain to another integer in the same range.
domain must be at least 1,000,000 (100 with allow_small_domain=True);
a smaller domain or an out-of-range value raises DomainError. Results
depend only on (key, domain, tweak), not on the instance's
radix/alphabet, so the construction is stable across instances and
releases.
Errors
KeyLengthError, AlphabetError, and DomainError all derive from
FFXError and from ValueError, so except FFXError catches every
validation failure. Arguments of the wrong type (a non-bytes key or tweak,
a non-str message, a non-int radix, domain, or value) raise TypeError.
Security notes
- Deterministic: FF1 is a deterministic permutation. Equal plaintexts under the same key and tweak produce equal ciphertexts. Use tweaks (e.g. a record identifier) to prevent cross-record equality leakage.
- Domain minimums: small domains are fundamentally weak for FPE. The
default floor of 1,000,000 follows Draft SP 800-38G Rev 1;
allow_small_domain=Trueopts into the original floor of 100; use it only when you understand the risk. - Always use cryptographically random keys (e.g.
secrets.token_bytes(16)).
Migrating from v1
v2 is a rewrite with a new API. v1 implemented the FFX[radix] addendum profile, which is FF1 with the tweak taken as a numeral string instead of raw bytes. FF1 with the old tweak string encoded as ASCII bytes reproduces v1 FFX[radix] ciphertexts exactly, with the zero-tweak exception below.
# v1 encrypted "0123456789" under the tweak string "9876543210"; in v2:
FF1(key, radix=10).encrypt("0123456789", tweak=b"9876543210") # '6124200773'
Behaviour changes to check before decrypting v1 data with v2:
- Zero-valued tweaks. v1 treated any tweak whose numeric value was zero
(
"0","0000000000", the integer0, ...) as no tweak. Decrypt such records withtweak=b""(the default), not with the zero string encoded as bytes. - Case. v1 accepted uppercase input for radix > 10 and rendered output in lowercase. v2 is case-exact and its radix-N alphabets are lowercase, so lowercase v1 ciphertexts before passing them to v2.
- Minimum length. v1 enforced no minimum. v2 requires
n >= 2andradix**n >= 1_000_000by default. Data with100 <= radix**n < 1_000_000needsallow_small_domain=True; data withradix**n < 100(including every one-numeral message) cannot be decrypted by v2 at all and must be re-encrypted in a larger format with v1 before upgrading. - Python 3.9 is no longer supported.
API changes:
| v1 | v2 |
|---|---|
ffx.new(key, radix), FFXEncrypter |
FF1(key, radix=...) |
enc.encrypt(tweak, FFXInteger) / enc.decrypt(tweak, FFXInteger) |
cipher.encrypt(str, tweak=bytes) / cipher.decrypt(str, tweak=bytes) |
FFXInteger |
plain str for numeral strings; int with encrypt_int / decrypt_int |
FFXException |
FFXError |
InvalidRadixException |
AlphabetError |
UnknownTypeException |
TypeError |
long_to_bytes, bytes_to_long |
removed; use int.to_bytes / int.from_bytes |
pycryptodome dependency |
cryptography |
Links
- Source, issues, and full README
- NIST SP 800-38G
- Test vectors: the official NIST FF1 samples and the legacy Voltage Security FFX[radix] vectors, both verified by the test suite
License
MIT License
Metadata
Release files for libffx 2.0.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| libffx-2.0.1.tar.gz | 32.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| libffx-2.0.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 45.1 kB
Release files / libffx-2.0.1.tar.gz
| Download URL | libffx-2.0.1.tar.gz |
|---|---|
| Size | 32.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
dcc5fcc78b2b80850e4f491ae32fb843f27554cae34b945c43807bfc0122dd0f
|
|
BLAKE2b-256 checksum How to use checksums |
b935b70ad025457aa2e97c2dbd98f755056aa08a6292fd6d467ffb9bbab601ec
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 2, 2026.
Transparency logRelease files / libffx-2.0.1-py3-none-any.whl
| Download URL | libffx-2.0.1-py3-none-any.whl |
|---|---|
| Size | 12.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2406b50ed4f331fe8f759be59b439d132bdc263d8f69238972bf32ea94187ed8
|
|
BLAKE2b-256 checksum How to use checksums |
5da0ed96103a03f4b0d1973bf928ca7ff962ca5ef6c229dc2ed25a2819788423
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 2, 2026.
Transparency log