fpr-ff1
FF1 format-preserving encryption (NIST SP 800-38G) for Python and Rust, conformance-tested round by round against the published NIST vectors.
- Python (PyPI): pure Python with one dependency,
cryptography, plus an optional compiled backend that is faster and bit-identical. - Rust (crates.io): the same FF1 core as a native crate with a small, stable API, validating inputs exactly as the Python package does.
Both are built from this repository, share one conformance suite, and are released together under one version number.
What FF1 is for
Format-preserving encryption turns a value into ciphertext of the same shape: a 16-digit card
number encrypts to 16 digits, and a code over A-Z0-9 stays over A-Z0-9. Encrypted values still
fit the columns, fixed-width files, validators and APIs built for the plaintext, so you can protect:
- card and account numbers in legacy schemas;
- national and customer identifiers passed between services;
- production data copied into test and analytics environments, still valid for the code that reads it.
FF1 is reversible encryption, not hashing or vault tokenisation: anyone holding the key can decrypt. It provides confidentiality only, so read the security notes.
Install
pip install fpr-ff1 # Python 3.12 or newer
cargo add fpr-ff1 # Rust 1.89 or newer
Quick start
import secrets
from fpr_ff1 import FF1
key = secrets.token_bytes(32) # for real data, load the key from your secret store
ff1 = FF1(
key=key,
radix=10,
alphabet="0123456789",
# Derive the tweak from stable record context (an account ID, a table name):
# equal plaintexts under the same key and tweak give equal ciphertexts.
tweak=b"customer-pans",
)
encrypted = ff1.encrypt("123456")
assert ff1.decrypt(encrypted) == "123456"
In Rust, with NIST's published sample 2 values, so the output can be checked:
use fpr_ff1::FF1;
fn main() -> Result<(), fpr_ff1::Error> {
// NIST SP 800-38G sample 2: AES-128, radix 10, a ten-byte tweak. The key is a
// published test key; load real keys from your secret store.
let key: [u8; 16] = [
0x2B, 0x7E, 0x15, 0x16, 0x28, 0xAE, 0xD2, 0xA6,
0xAB, 0xF7, 0x15, 0x88, 0x09, 0xCF, 0x4F, 0x3C,
];
let ff1 = FF1::builder(&key, 10)
.alphabet("0123456789")
.tweak(b"9876543210")
.build()?;
let ciphertext = ff1.encrypt("0123456789", None)?;
assert_eq!(ciphertext, "6124200773");
assert_eq!(ff1.decrypt(&ciphertext, None)?, "0123456789");
Ok(())
}
Check it against NIST
The same NIST sample 2 check in Python. Its key is a published test key, for checking only:
nist = FF1(
key=bytes.fromhex("2B7E151628AED2A6ABF7158809CF4F3C"),
radix=10,
alphabet="0123456789",
tweak=b"9876543210",
)
assert nist.encrypt("0123456789") == "6124200773"
Security notes — read before use
FF1 is a deterministic permutation for a fixed key and tweak. That has operational consequences:
- Equal plaintexts produce equal ciphertexts under the same key and tweak. NIST recommends varying the tweak with each encryption instance where feasible — derive it from stable record context so identical plaintexts in different records encrypt differently.
- FF1 provides confidentiality only — no integrity or authentication. Modified ciphertext decrypts to another plausible in-domain value; there is no error, and the tampering is invisible. Applications needing tamper detection must authenticate the ciphertext and its context separately, where their format allows.
- A wrong key or tweak does not raise. Decryption with the wrong key or tweak yields plausible-looking plaintext, not an exception. There is no way to detect key mismatch from the output alone.
- The one-million-value domain is a standards floor, not a guarantee.
radix ** minlen >= 1_000_000rules out trivially enumerable domains, but a determined attacker with oracle access can still search a million-value space. Small-domain FPE needs rate limiting or access control around the encryption interface. - Validation exceptions never echo your data. Rejected values are located by index, not repeated in the message, so a malformed record does not leak plaintext into your logs.
Why you can trust this implementation
A subtly wrong FF1 still round-trips perfectly while producing ciphertext no conformant implementation can read, and by the time anyone notices, the data is written. So conformance is the product here, and it is evidenced rather than asserted:
- All nine NIST sample vectors in both directions, and every per-round intermediate value NIST publishes, for every round of every sample. Compensating bugs can pass an output test; they cannot pass this one.
- Differential tests against an independent implementation across radices 2, 10, 16, 32, 36, 62, 256 and 65535, at every length where FF1's block structure changes. NIST publishes vectors for radix 10 and 36 only. The oracle is checked against NIST first, and its outputs are frozen into the repository.
- Exhaustive bijectivity over two whole domains: radix 2 at length 20 and radix 10 at length 6.
- Vectors transcribed from the NIST document, never regenerated from this code.
- 100% line and branch coverage of the Python package, enforced in CI. The Rust core passes the whole Python conformance suite bit for bit, and its own tests of the same fixtures.
Each known way FF1 implementations go wrong has a dedicated test:
| Known failure mode | How it is prevented |
|---|---|
Floating-point ceil(v · log₂(radix)) — the Bouncy Castle bug class |
Exact integer arithmetic; an AST scan fails the build if math.log, ceil, / or any float literal appears in the core |
b derived from u instead of v |
Asserted against the traced value, not a round-trip — which passes either way |
Wrong S expansion when d > 16 |
Differential cases at each block-count transition; no NIST sample reaches this branch |
| Mirrored parity rule in decrypt | Encrypt and decrypt share one code path for it |
| Silent coercion of bad input | Every rejection raises a typed exception; a 50-case sweep asserts nothing escapes as a bare AttributeError or KeyError |
This is strong conformance evidence, not proof: see what this is not.
Why FF1 only — and why FF3 is excluded
SP 800-38G originally specified FF1 and FF3. FF3 was revised to FF3-1 after an attack, but Beyne then showed a weakness in the tweak schedule that affects FF3 and FF3-1 alike, and the February 2025 second public draft of SP 800-38G Rev. 1 removes FF3 entirely. FF1 is the only format-preserving mode left standing, so this project will never implement FF3 or FF3-1: no flag, no opt-in, no plan to add one.
Limits
SP 800-38G, with the tighter limits of its Rev. 1 second public draft. Inputs below the minimum
domain fail closed with LengthError, even where older libraries accept them.
| Constraint | Value |
|---|---|
| Minimum domain | radix ** minlen >= 1_000_000: 6 numerals at radix 10, 4 at radix 36 |
| Maximum length | 2 ** 32 - 1 numerals; tweaks up to 2 ** 32 - 1 bytes |
| Key sizes | AES-128, AES-192, AES-256 |
| Radix | 2 <= radix < 2**16, a deliberate subset of the specification's range |
Why each limit is where it is: docs/configuration.md.
Backends and platforms
In Python, FF1(..., backend="rust") opts in to the compiled backend. backend="python" is the
default and the reference implementation. Both produce bit-identical ciphertext and raise identical
exceptions, because validation runs in Python for both.
Native wheels carry the compiled backend for Linux x86_64 and aarch64 (glibc 2.34 or newer), macOS
on Intel and Apple silicon, and Windows x64, on CPython 3.12 to 3.14 (wheel list).
Elsewhere, including musl, older glibc and free-threaded builds, pip installs the pure-Python
wheel, and backend="rust" raises BackendError rather than falling back silently.
Performance
just bench on 2026-09-27: one core, Linux x86_64 (AMD Ryzen AI Max+ PRO 395), CPython 3.12.13,
extension built in release mode with rustc 1.98.1. Microseconds per encryption:
| Input | backend="python" |
backend="rust" |
Speedup |
|---|---|---|---|
| 6 numerals, radix 10 | 28.2 | 4.6 | 6.20× |
| n = 100, radix 10 | 108.7 | 39.0 | 2.79× |
| n = 1,000, radix 10 | 937.8 | 397.5 | 2.36× |
| n = 5,000, radix 10 | 5,212.3 | 2,117.5 | 2.46× |
| n = 20,000, radix 10 | 26,770.3 | 9,774.2 | 2.74× |
| n = 100, radix 256 | 141.0 | 51.3 | 2.75× |
| n = 1,000, radix 256 | 2,542.9 | 211.4 | 12.03× |
| n = 5,000, radix 256 | 13,105.2 | 1,046.2 | 12.53× |
| n = 20,000, radix 256 | 52,585.5 | 4,167.8 | 12.62× |
The compiled backend releases the GIL: in the same run, four threads sharing one instance did the
same work 2.17× faster than one thread (n = 5,000, radix 10), against 0.86× for pure Python. These
are one machine's numbers; measure your own data with just bench.
Python API at a glance
assert ff1.decrypt_numerals(ff1.encrypt_numerals([1, 2, 3, 4, 5, 6])) == [1, 2, 3, 4, 5, 6]
assert ff1.decrypt(ff1.encrypt("123456", b"per-call"), b"per-call") == "123456" # per-call tweak
assert ff1.min_length == 6
Numerals need no alphabet. Every rejection raises a typed exception derived from FF1Error that
never echoes your data. Instances are thread-safe and picklable (pickling serialises the key).
Full reference: docs/python-api.md.
Rust API: docs.rs.
Migrating from ubiq_security_fpe
fpr-ff1 replaces the deprecated, unmaintained ubiq_security_fpe. The two produce identical
ciphertext in both directions, enforced by tests/test_interoperability.py, so existing data
stays readable without re-encryption. One trap: the legacy twk_max_len=0 meant "no maximum",
which here is max_tweak_len=None; a literal 0 means empty tweaks only. The
migration guide has the API mapping and every behaviour change.
What this is not
- Not FIPS validated. Passing the published NIST sample vectors is conformance evidence, not FIPS 140 validation, and no such claim is made.
- Not independently audited, and not constant-time; see
SECURITY.md. - No key zeroization. Python
bytesare immutable and the interpreter may copy them; the Rust crate holds keys in ordinary heap memory and does not wipe them. - Not a key-management or tokenisation toolkit, permanently. No key generation, storage or derivation, identifier generation, persistence, checksums or application-specific alphabets.
Documentation
- Python API reference ·
Migrating from
ubiq_security_fpe· Configuration and limits - Rust crate · Rust API on docs.rs
- Architecture · Developer guide · Changelog · Security policy · Contributing · Code of conduct
Development
Requires Python 3.12, uv and just; the compiled backend and the crate also need Rust.
just setup # create venv and install deps
just quality # format check, lint, typecheck, tests
License
The Python package is MIT. The Rust crate is MIT OR Apache-2.0, at your option; its sources are also compiled into the Python platform wheels, where the MIT option applies.
Metadata
Release files for fpr-ff1 2.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| fpr_ff1-2.1.0.tar.gz | 935.5 kB | With provenance | Details |
Built distributions (wheels)
| File | Reset | |||
|---|---|---|---|---|
| fpr_ff1-2.1.0-py3-none-any.whl | Python 3 | none | any | Details |
| fpr_ff1-2.1.0-cp312-abi3-win_amd64.whl | CPython 3.12 | abi3 | Windows x86-64 | Details |
| fpr_ff1-2.1.0-cp312-abi3-manylinux_2_34_x86_64.whl | CPython 3.12 | abi3 | Linux glibc 2.34+ x86-64 | Details |
| fpr_ff1-2.1.0-cp312-abi3-manylinux_2_34_aarch64.whl | CPython 3.12 | abi3 | Linux glibc 2.34+ ARM64 | Details |
| fpr_ff1-2.1.0-cp312-abi3-macosx_11_0_arm64.whl | CPython 3.12 | abi3 | macOS 11.0+ ARM64 | Details |
| fpr_ff1-2.1.0-cp312-abi3-macosx_10_12_x86_64.whl | CPython 3.12 | abi3 | macOS 10.12+ x86-64 | Details |
Total release size: 2.4 MB
Release files / fpr_ff1-2.1.0.tar.gz
| Download URL | fpr_ff1-2.1.0.tar.gz |
|---|---|
| Size | 935.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
3419da24425fb6b411fd7fcc4da9f611884dac07a7b38339dd5c94212c4d3ca5
|
|
BLAKE2b-256 checksum How to use checksums |
46131ed0acfcbf45896dfd2dc4837bb67cf6ae6c1fccb49abad87e6876d62ee6
|
| 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 28, 2026.
Transparency logRelease files / fpr_ff1-2.1.0-py3-none-any.whl
| Download URL | fpr_ff1-2.1.0-py3-none-any.whl |
|---|---|
| Size | 21.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
ed9dbfcad32da929be6a3fc183ca10cdeeb7274073fa231dbf13066345b2ab60
|
|
BLAKE2b-256 checksum How to use checksums |
d2b07bb8622d086ccafe6741d0458fad033177a704186ab4504ad03bd30de69a
|
| 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 28, 2026.
Transparency logRelease files / fpr_ff1-2.1.0-cp312-abi3-win_amd64.whl
| Download URL | fpr_ff1-2.1.0-cp312-abi3-win_amd64.whl |
|---|---|
| Size | 200.0 kB |
| Tags | CPython 3.12 Windows x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
1b8cc219ec02d9c3e036ea4813a8d50bfa51f25a50cb1d3186ed6d7db9efa1d6
|
|
BLAKE2b-256 checksum How to use checksums |
6225228e4ee3c0ee90a7a94264e2172b29072bad912c6e6b95f512e094d77ff6
|
| 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 28, 2026.
Transparency logRelease files / fpr_ff1-2.1.0-cp312-abi3-manylinux_2_34_x86_64.whl
| Download URL | fpr_ff1-2.1.0-cp312-abi3-manylinux_2_34_x86_64.whl |
|---|---|
| Size | 342.3 kB |
| Tags | CPython 3.12 Linux glibc 2.34+ x86-64 abi3 |
|
SHA-256 checksum How to use checksums |
ed3133cbd7ac8fc6cf793d34e7214521128568109b04afb55b092d9a5601eee7
|
|
BLAKE2b-256 checksum How to use checksums |
4e46b536160039854320923a74a70953be40161a665235f430d196948e399bc3
|
| 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 28, 2026.
Transparency logRelease files / fpr_ff1-2.1.0-cp312-abi3-manylinux_2_34_aarch64.whl
| Download URL | fpr_ff1-2.1.0-cp312-abi3-manylinux_2_34_aarch64.whl |
|---|---|
| Size | 326.9 kB |
| Tags | CPython 3.12 Linux glibc 2.34+ ARM64 abi3 |
|
SHA-256 checksum How to use checksums |
a71a1b9918e1762a7d3e5e85cdd5801f6c3a9409f845829bb3f98c71403a7efe
|
|
BLAKE2b-256 checksum How to use checksums |
942e87742eccd3633b57db5a004b4f091d43c2456fc0a830e91cfd32c6c5526e
|
| 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 28, 2026.
Transparency logRelease files / fpr_ff1-2.1.0-cp312-abi3-macosx_11_0_arm64.whl
| Download URL | fpr_ff1-2.1.0-cp312-abi3-macosx_11_0_arm64.whl |
|---|---|
| Size | 282.2 kB |
| Tags | CPython 3.12 abi3 macOS 11.0+ ARM64 |
|
SHA-256 checksum How to use checksums |
e9eca1cb1bbd02b1dcef7811f60d46e1555899b89a55d01f9405cd89d355699b
|
|
BLAKE2b-256 checksum How to use checksums |
790fcbcabb0039dd9889f47027862451c5ec44e0d2c8627736df63d4eb4f09df
|
| 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 28, 2026.
Transparency logRelease files / fpr_ff1-2.1.0-cp312-abi3-macosx_10_12_x86_64.whl
| Download URL | fpr_ff1-2.1.0-cp312-abi3-macosx_10_12_x86_64.whl |
|---|---|
| Size | 300.5 kB |
| Tags | CPython 3.12 abi3 macOS 10.12+ x86-64 |
|
SHA-256 checksum How to use checksums |
bdd135f5187ce690157e7e21096c222825e673898a1218d84ad0781a4761a0cf
|
|
BLAKE2b-256 checksum How to use checksums |
aff6d5520a5a1f840d9b8c85cf8f398e875421f6ea2a3dc1d317babd85ae05f3
|
| 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 28, 2026.
Transparency logSecurity for fpr-ff1 2.1.0
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.
Full provenance
All 7 files in this release have been uploaded with provenance metadata.
Attestation for fpr_ff1-2.1.0.tar.gz
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 28, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.