fpr-ff1 2.1.1

NIST SP 800-38G FF1 format-preserving encryption: exact integer arithmetic, conformance-tested against the published vectors.
Documentation

fpr-ff1

crates.io docs.rs MSRV 1.89 License: MIT OR Apache-2.0

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

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 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 are u16.
  • 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 - 1 numerals, and a tweak of at most 2**32 - 1 bytes.
  • 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.