Expand description
§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
cargo add fpr-ff1Requires 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 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(())
}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 fpr_ff1::FF1;
fn main() -> Result<(), fpr_ff1::Error> {
// NIST SP 800-38G sample 1: the same published test key, no tweak.
let key: [u8; 16] = [
0x2B, 0x7E, 0x15, 0x16, 0x28, 0xAE, 0xD2, 0xA6,
0xAB, 0xF7, 0x15, 0x88, 0x09, 0xCF, 0x4F, 0x3C,
];
let ff1 = FF1::new(&key, 10)?;
let ciphertext = ff1.encrypt_numerals(&[0, 1, 2, 3, 4, 5, 6, 7, 8, 9], None)?;
assert_eq!(ciphertext, [2, 4, 3, 3, 4, 7, 7, 4, 8, 4]);
Ok(())
}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 fpr_ff1::{ErrorKind, FF1};
fn main() -> Result<(), fpr_ff1::Error> {
let key = [0u8; 16]; // for the example only
let ff1 = FF1::new(&key, 10)?;
let err = ff1.encrypt_numerals(&[1, 2, 3], None).unwrap_err();
assert_eq!(err.kind(), ErrorKind::Length);
assert_eq!(err.to_string(), "plaintext length 3 below minimum 6 for radix 10");
Ok(())
}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.
§Relationship to the Python package
Validation matches the fpr-ff1 Python package rule for rule, in the same
order and with the same messages, so an input either both accept or both
reject in the same way. ErrorKind maps one to one onto the Python
exception classes.
No type from num-bigint, aes or cipher appears in the public API
(plan 00009 D10), so their major versions never become this crate’s.
Structs§
- Builder
- Configures an
FF1; obtained fromFF1::builder. - Error
- A rejected input.
- FF1
- An FF1 cipher: a key, a radix and optional configuration, validated once.
Enums§
- Error
Kind - What kind of input was rejected.