fpr-ff1
FF1 format-preserving encryption (NIST SP 800-38G) for Rust, conformance-tested round by round against the published NIST vectors.
Format-preserving encryption turns a value into ciphertext of the same shape. A 16-digit card
number encrypts to 16 digits, a 9-digit identifier to 9 digits, and a code over A-Z0-9 stays over
A-Z0-9. Encrypted values still fit the column types, fixed-width files, validators and APIs built
for the plaintext, so you can protect data in systems you cannot redesign:
- card numbers 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 (see What this is not).
Install
Requires Rust 1.89 or newer. The direct dependencies are RustCrypto's aes and the
num-bigint family. API documentation is on docs.rs.
Quick start
use FF1;
The key and tweak above are NIST's published sample 2 values, so this example is also a
conformance check: 6124200773 is the ciphertext NIST publishes. Never use a published key for real
data: load keys from your secret store, and derive the tweak from stable record context (a table
name, a tenant ID) so that equal values in different contexts encrypt differently.
The numeral interface is the primitive and needs no alphabet. This is NIST sample 1:
use FF1;
Every rejection is an
Error whose
kind() says what was
wrong. Messages name the position or length at fault, never key material or plaintext:
use ;
FF1 is Clone, Send and Sync. It holds no cipher state between calls, so one instance can
serve every thread.
Why trust it
A subtly wrong FF1 still round-trips perfectly while producing ciphertext no conformant implementation can read. So the evidence here goes beyond "decrypt inverts encrypt". Each item below is a test in this crate, run in CI on Linux, macOS and Windows; all but the exhaustive sweeps also run on Rust 1.89:
- All nine NIST SP 800-38G samples, in both directions.
- Every per-round intermediate value NIST publishes, for every round of every sample. Two compensating bugs can pass an output test; they cannot pass this one.
- 46 frozen vectors from an independent implementation over radices 2, 10, 16, 32, 36, 62, 256
and 65535, with inputs of up to 193 numerals. These cover the long-input path (
d > 16) that no NIST sample reaches. - The FIPS 197 AES known-answer vectors for the block cipher underneath.
- Exhaustive bijectivity: every one of the 1,048,576 values at radix 2, length 20, and the 1,000,000 values at radix 10, length 6, map to distinct ciphertexts.
- Property tests for round trip, determinism, and key and tweak sensitivity.
- Exact integer arithmetic, enforced by a test that scans the crate's source for floating-point
types, functions and literals. A floating-point
ceil(v * log2(radix))is the bug class that has broken other FF1 implementations.
This is the same core that runs behind the Python package's backend="rust", where the full Python
conformance suite checks it bit for bit against the pure-Python reference implementation.
Limits
- Keys: 128, 192 or 256 bits.
- Radix:
2 <= radix < 2**16, a deliberate subset of the specification's range; numerals areu16. - Minimum domain:
radix**minlen >= 1_000_000, from the Rev. 1 draft (stricter than the 2016 text), so the shortest input is 6 numerals at radix 10 and 20 at radix 2. - Maximum length:
2**32 - 1numerals, and a tweak of at most2**32 - 1bytes. - FF1 only. FF3 and FF3-1 are deliberately absent: the second public draft of SP 800-38G Rev. 1 removes FF3 after a published weakness in its tweak schedule.
What this is not
- No integrity. Modified ciphertext decrypts to another plausible value without an error, and a wrong key or tweak does not fail either. Authenticate the ciphertext separately if you need to detect tampering.
- Not FIPS validated. Passing the published NIST vectors is conformance evidence, not validation.
- Not independently audited.
- Not constant-time. The big-integer arithmetic runs in time that depends on the values processed. If your threat model includes a local timing adversary, this is not the right implementation.
- No key zeroization. Key material is held in ordinary heap memory and is not wiped.
- No key management. Generating, storing, deriving and rotating keys is the caller's job.
Testing
The conformance tests read the shared fixtures in the repository's tests/vectors/ directory
-- the NIST samples and per-round intermediates, the FIPS 197 AES vectors, the frozen oracle
vectors and the validation cases -- which a published crate cannot contain. Run them from a
checkout of the repository. In the packaged crate those tests skip, and because Rust's test
harness hides a passing test's output, the skip is not visible: set FPR_FF1_REQUIRE_FIXTURES=1
to turn a missing fixture into a failure, as the repository's own runs and CI always do.
Also available for Python
The same FF1 core ships in the fpr-ff1 Python package,
whose pure-Python implementation is the reference. The two produce bit-identical ciphertext and
validate inputs with the same rules, order and messages, checked by a case file both test suites
share. The crate's version always matches the Python package's. Source, issues and the security
policy are at github.com/joelee/fpr-ff1.
Licence
Licensed under either of the Apache License, Version 2.0, or the MIT licence, at your option.