Skip to content

Repository files navigation

panfpe

NIST SP 800-38G Rev.1 FF1 and FF3-1 format-preserving encryption for card tokens, in zero-dependency TypeScript, checked against official vectors.

The problem

Payment systems need card tokens that still look like cards: same length, the same first six (BIN) and last four digits, and a valid Luhn check digit, so old validation code keeps working. Teams either pay for a proprietary vault or copy a Feistel snippet with no test vectors. Those snippets usually get two things wrong. They implement FF3, which NIST withdrew after the Durak-Vaudenay attack and replaced with FF3-1. And they ignore the Rev.1 rule that the domain must hold at least 1,000,000 values: a 16-digit card that keeps 6+4 has 6 free digits, and forcing a valid Luhn digit leaves only 100,000 possible tokens.

How it works

Everything runs on node:crypto AES-ECB, one reusable context per key. Numeral strings are converted with BigInt, so any radix from 2 to 65536 works.

FF1 (src/ff1.ts, Algorithms 7/8). A 10-round Feistel over halves A (u = floor(n/2) numerals) and B (v = n - u). Each round builds Q = T || 0^pad || [i] || [NUM_radix(B)]_b, computes the PRF as AES CBC-MAC with a zero IV over the fixed block P || Q, then stretches the result to d = 4*ceil(b/4)+4 bytes as R || AES(R xor [1]) || AES(R xor [2]) .... The round adds that value to A mod radix^m. b is computed exactly as the byte length of radix^v - 1, not with floating-point log2.

FF3-1 (src/ff3_1.ts, Algorithms 9/10). 8 rounds, u = ceil(n/2). The 56-bit tweak is split as TL = T[0..27] || 0^4 and TR = T[32..55] || T[28..31] || 0^4. Hand-written versions usually break on the reversal conventions. AES is keyed with REVB(K). Each round block W xor [i] || NUM_radix(REV(B)) is byte-reversed before and after encryption, and the halves are read and written least significant numeral first. Anything other than exactly 7 tweak bytes is rejected.

Domain rules (src/common.ts). minlen is the smallest length with radix^minlen >= 10^6 (6 digits for radix 10, 20 bits for radix 2, 4 characters for radix 36). FF1 caps length below 2^32. FF3-1 caps it at 2*floor(log_radix(2^96)), which is 56 for decimal digits. The test suite turns the check off to enumerate tiny domains. Production code has no reason to.

PAN tokenizer (src/pan.ts). Keep the first k1 digits (BIN, 6 or 8) and the last k2 (normally 4). Encrypt the middle with a tweak bound to the kept digits. FF1 gets the bytes "6:411111:4:1111"; FF3-1 gets the first 7 bytes of their SHA-256. Then cycle-walk: re-encrypt until the whole PAN passes Luhn.

4111111111111111   keep 411111 | 111111 | 1111
middle 111111 --E--> 117231 (Luhn fails) --E--> 241313 (fails) ... --E--> 215256 (fails) --E--> 536622 (passes)
token  4111115366221111   (27 cipher calls for this card; the mean is 10)

For a fixed tweak, E is a permutation of the 10^6 middles. Walking forward from one Luhn-valid point to the next is therefore a permutation of the Luhn-valid subset, and walking backward with D inverts it. For every choice of the other free digits exactly one digit value makes the PAN valid, so there are exactly 10^(free-1) valid middles. Over a full permutation the mean walk is 10^free / 10^(free-1) = 10 cipher calls.

Auditor (src/audit.ts). For a card length and a k1+k2 policy it reports the cipher domain 10^free, the token domain 10^(free-1) (or 10^free with --no-luhn), whether the token domain meets 10^6, the expected walk length, and the most digits you could keep and still comply. The tokenizer uses the same rule and refuses non-compliant policies unless you pass allowNonCompliant.

Install and usage

Requires Node.js 20 or newer. No runtime dependencies.

git clone <this repo> panfpe && cd panfpe
npm ci
npm test          # build + 75 tests
npm run bench     # the benchmark below
npm link          # optional: puts `panfpe` on your PATH; otherwise use `node dist/src/cli.js`

Encrypt digit strings (NIST FF1 sample 2, then the FF3-1 known answer):

$ panfpe enc --key 2B7E151628AED2A6ABF7158809CF4F3C --tweak 39383736353433323130 0123456789
6124200773
$ panfpe enc --alg ff3-1 --key 2DE79D232DF5585D68CE47882AE256D6 --tweak CBD09280979564 3992520240
8901801106
$ panfpe dec --alg ff3-1 --key 2DE79D232DF5585D68CE47882AE256D6 --tweak CBD09280979564 8901801106
3992520240
$ panfpe enc --alg ff3-1 --key 2B7E151628AED2A6ABF7158809CF4F3C --tweak 0011 1234567
panfpe: FF3-1 tweak must be exactly 56 bits (7 bytes), got 16 bits

--radix N and --alphabet CHARS select other alphabets (default 0-9a-z truncated to the radix).

Audit a policy:

$ panfpe audit --length 16 --keep 6+4
card length        16
kept digits        6 leading + 4 trailing
free digits        6
cipher domain      10^6
token domain       10^5  (Luhn-valid tokens only)
NIST minimum       10^6  (SP 800-38G Rev.1)
expected walks     10 cipher calls per token
max kept digits    9 for a compliant 16-digit policy
verdict            NON-COMPLIANT: cipher domain passes, but Luhn leaves too few tokens

$ panfpe audit --length 19 --keep 8+4
card length        19
kept digits        8 leading + 4 trailing
free digits        7
cipher domain      10^7
token domain       10^6  (Luhn-valid tokens only)
NIST minimum       10^6  (SP 800-38G Rev.1)
expected walks     10 cipher calls per token
max kept digits    12 for a compliant 19-digit policy
verdict            COMPLIANT

--json prints the same report as JSON. --check exits with status 1 on a non-compliant policy, which lets you use it as a CI gate.

Tokenize cards:

$ panfpe tokenize --key 2B7E151628AED2A6ABF7158809CF4F3C 4111111111111111
panfpe: keeping 6+4 of 16 digits leaves 6 free digits: 100000 Luhn-valid tokens, below the SP 800-38G minimum of 1000000 (override with allowNonCompliant, or --allow-noncompliant on the CLI)
$ panfpe tokenize --key 2B7E151628AED2A6ABF7158809CF4F3C --allow-noncompliant 4111111111111111
4111115366221111
$ panfpe detokenize --key 2B7E151628AED2A6ABF7158809CF4F3C --allow-noncompliant 4111115366221111
4111111111111111
$ panfpe tokenize --alg ff3-1 --keep 8+4 --key 2B7E151628AED2A6ABF7158809CF4F3C 4000000000000000006
4000000051482180006
$ panfpe detokenize --alg ff3-1 --keep 8+4 --key 2B7E151628AED2A6ABF7158809CF4F3C 4000000051482180006
4000000000000000006

As a library:

import { FF1, FF3_1, PanTokenizer, audit } from "panfpe";

const ff1 = new FF1("2B7E151628AED2A6ABF7158809CF4F3C", { radix: 36 });
ff1.encrypt("0123456789abcdefghi", "3737373770717273373737"); // "a9tv40mll9kdu509eum"

const t = new PanTokenizer("2B7E151628AED2A6ABF7158809CF4F3C", { algorithm: "ff3-1", keepFirst: 8, keepLast: 4 });
t.tokenize("4000000000000000006");   // "4000000051482180006"
t.detokenize("4000000051482180006"); // "4000000000000000006"
audit({ length: 16, keepFirst: 6, keepLast: 4 }).compliant; // false

Which policies comply

Output of panfpe audit --table. Each cell is the number of Luhn-valid tokens and whether it reaches the SP 800-38G Rev.1 minimum of 10^6.

Length keep 6+4 keep 8+4 keep 6+0 keep 0+4
13 10^2 no 10^0 no 10^6 yes 10^8 yes
14 10^3 no 10^1 no 10^7 yes 10^9 yes
15 10^4 no 10^2 no 10^8 yes 10^10 yes
16 10^5 no 10^3 no 10^9 yes 10^11 yes
17 10^6 yes 10^4 no 10^10 yes 10^12 yes
18 10^7 yes 10^5 no 10^11 yes 10^13 yes
19 10^8 yes 10^6 yes 10^12 yes 10^14 yes

Keeping the BIN and last four, which is the industry default, is non-compliant for every card shorter than 17 digits. With an 8-digit BIN it only complies at 19 digits. A Luhn-preserving policy needs at least 7 free digits.

Results

Correctness

npm test runs 75 tests in about 4 seconds:

  • Known answers. All nine NIST FF1 samples (AES-128/192/256; radix 10 with and without a tweak; radix 36) round-trip in both directions. The FF3-1 known answer with a 56-bit tweak matches. The five NIST FF3 AES-128 samples (radix 10 and 26, including an all-zero tweak) run through the FF3-1 Feistel core, which FF3 and FF3-1 share. That pins the REVB and REV conventions against an official source.
  • Exhaustive permutation. With the domain check off, both ciphers encrypt every value of radix-10 length 4 and 3 and radix-2 length 12 and 11. The tests assert no collisions, a full image, and decrypt as the inverse. Odd lengths exercise the unequal u/v split.
  • Tokenizer permutation. On a 14-digit format (6 + 4 free + 4), every one of the 1,000 Luhn-valid inputs maps to a distinct Luhn-valid token with the same BIN and last four. The token set equals the input set, and detokenize inverts every token.
  • Properties. 10,000 random (key size, key, radix from 2 to 65536, length, tweak) cases per algorithm check the round trip, preserved length and alphabet, and that flipping any one tweak bit changes the ciphertext. That last check runs on a domain of at least 2^64 so an accidental match is negligible.
  • Rejections. A domain under 10^6 at each radix's exact boundary, FF3-1 tweaks of 0/8/48/64 bits, characters and numerals outside the alphabet, bad key sizes and radixes, the FF3-1 maximum length, and a card whose kept digits leave no free digits.
  • Auditor. Hand-computed domains for 13-, 15-, 16- and 19-digit cards, plus a cross-check that the tokenizer accepts exactly the policies the auditor calls compliant for every length from 12 to 19.

Throughput and cycle-walk length

npm run bench tokenizes 100,000 pseudo-random (fixed-seed) Luhn-valid 16-digit PANs with BIN 411111, keeping 6+4 with allowNonCompliant, AES-128. Measured on an Apple M2, Node 24.12.0, macOS, single thread:

Algorithm Tokens/s Cipher calls/s Mean walks Max walks Tokens > 50 walks
FF1 5,559 55,720 10.023 108 497
FF3-1 8,415 83,939 9.975 106 525

The measured mean matches the analytic 10. Walk lengths are roughly geometric with p = 1/10, so about 0.5% of tokens need more than 50 cipher calls (0.9^50 ≈ 0.52%). A tokenization call has no fixed latency bound. FF3-1 is faster here because it runs 8 rounds of one AES block each. FF1 runs 10 rounds of a 3-block CBC-MAC: the fixed block P plus two Q blocks, because the 15-byte tweak pushes Q past 16 bytes. Set PANFPE_BENCH_TOKENS to change the sample size.

Design notes

The tokenizer enforces the domain rule on the token space, not the cipher space. For a 16-digit card with 6+4 kept, the FF1 call itself sees 6 digits, which is exactly 10^6 and passes the letter of SP 800-38G. The tokens an attacker can observe, though, come from only 10^5 Luhn-valid values: cycle-walking narrows the range, not the cipher. I made the tokenizer refuse that by default and put the override (allowNonCompliant) in the caller's code, where a reviewer will see it. The cost is that the most common production policy fails out of the box. That is the point, but it will surprise people. The cipher's own 10^6 check stays active even with the override, so 8+4 on a 16-digit card can never run.

Cycle-walking instead of fixing the check digit. The simpler approach encrypts the free digits except one and recomputes the Luhn digit. That is a permutation too, but it spends a free digit on the check and changes which digit position is "the" check digit. Cycle-walking keeps the whole middle under the cipher and makes the output uniform over the Luhn-valid set, at the price of variable latency (mean 10 calls, long tail). For FF3-1 the tweak is 56 bits, and the kept digits of an 8+4 policy (10^12 values plus the policy) do not fit injectively. I used a truncated SHA-256 of an unambiguous encoding. FF1 has a variable-length tweak and gets the encoding itself.

Limitations

  • FF3-1 official vectors are thin. I embedded one FF3-1 known answer with a 56-bit tweak: the vector published with the mysto FF3-1 reference implementations, not a NIST document. The NIST FF3 samples cover the shared round function and reversal conventions, and a unit test covers the Rev.1 tweak split. There is no NIST-published FF3-1 vector in the suite, and none for AES-192/256 FF3-1.
  • Not constant time. BigInt arithmetic, the variable cycle-walk count and Node's AES binding all leak timing. It is not hardened against side channels.
  • Speed. Thousands of tokens per second per core, not millions. The BigInt conversions and per-block Cipher.update calls dominate. It is fine for tokenizing at the edge of a request, and slow for re-tokenizing a large vault in bulk.
  • No key management. Keys are raw hex or Buffer. Rotation, storage and access control are yours.
  • Card validation is only Luhn and length (12-19 digits). No BIN-range, issuer or network checks. The BIN is simply the kept leading digits.
  • Not a compliance certification. The auditor applies SP 800-38G Rev.1's 10^6 minimum. It does not evaluate PCI DSS scope, and it does not weigh algorithm choice. NIST's 2025 draft of SP 800-38G Rev.2 proposes dropping FF3-1, so check the standard's current status before choosing it over FF1.
  • The minimum-domain bypass (unsafeSkipDomainCheck) is an option on the constructors, marked internal, not a separate test-only build.

License

MIT. See LICENSE.

About

NIST FF1 and FF3-1 format-preserving encryption for card tokens, checked against official vectors

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages