├── .github
└── workflows
│ └── build-py.yml
├── .gitignore
├── LICENSE
├── README.md
├── ff3
├── __init__.py
├── __main__.py
├── ff3.py
├── ff3_perf.py
└── ff3_test.py
├── requirements.txt
├── setup.cfg
└── setup.py
/.github/workflows/build-py.yml:
--------------------------------------------------------------------------------
1 | name: Python FPE CI
2 |
3 | on: [push]
4 |
5 | jobs:
6 | build:
7 |
8 | runs-on: ubuntu-latest
9 | strategy:
10 | matrix:
11 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
12 |
13 | steps:
14 | - uses: actions/checkout@v3
15 | - name: Set up Python ${{ matrix.python-version }}
16 | uses: actions/setup-python@v4
17 | with:
18 | python-version: ${{ matrix.python-version }}
19 | - name: Install dependencies
20 | run: |
21 | python -m pip install --upgrade pip
22 | pip install ruff pytest
23 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
24 | - name: Lint with ruff
25 | run: |
26 | # stop the build if there are Python syntax errors or undefined names
27 | ruff check . --output-format=github --select=E9,F63,F7,F82 --target-version=py38 .
28 | # default set of ruff rules with GitHub Annotations
29 | ruff check . --output-format=github --target-version=py38 --ignore F401 .
30 | - name: Test with pytest
31 | run: |
32 | pytest
33 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .*
2 | !.github/
3 | !.gitignore
4 | !.travis.yml
5 |
6 | # Byte-compiled / optimized / DLL files
7 | __pycache__/
8 | *.py[cod]
9 | *$py.class
10 |
11 | # Distribution / packaging
12 | build/
13 | develop-eggs/
14 | dist/
15 | downloads/
16 | eggs/
17 | *.egg-info/
18 | *.egg
19 |
20 | # Environments
21 | venv/
22 |
23 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://github.com/mysto/python-fpe/actions)
2 | [](https://coveralls.io/github/mysto/python-fpe?branch=main)
3 | [](https://opensource.org/licenses/Apache-2.0)
4 | 
5 | [](https://pepy.tech/project/ff3)
6 | [](https://badge.fury.io/py/ff3)
7 | [](http://isitmaintained.com/project/mysto/python-fpe "Percentage of issues still open")
8 |
9 |
10 |
11 |
15 |
16 |
17 |
18 | # FF3 - Format Preserving Encryption in Python
19 |
20 | An implementation of the NIST FF3 and draft FF3-1 Format Preserving Encryption (FPE) algorithms in Python.
21 |
22 | This package implements the FF3 algorithm for Format Preserving Encryption as described in the March 2016 NIST publication 800-38G _Methods for Format-Preserving Encryption_,
23 | and revised on February 28th, 2019 with a draft update for FF3-1.
24 |
25 | * [NIST Recommendation SP 800-38G (FF3)](http://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-38G.pdf)
26 | * [NIST Recommendation SP 800-38G Revision 1 (FF3-1)](https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-38Gr1-draft.pdf)
27 | * [NIST SP 800-38G Revision 1 (2nd Public Draft)](https://csrc.nist.gov/pubs/sp/800/38/g/r1/2pd)
28 |
29 | **NOTE:** NIST's Feburary 2025 Draft 2 has removed FF3 from the NIST standard. Contact me about a licensed version of FF1 in Python.
30 |
31 | Changes to minimum domain size and revised tweak length have been implemented in this package with
32 | support for both 64-bit and 56-bit tweaks. NIST has only published official test vectors for 64-bit tweaks,
33 | but draft ACVP test vectors have been used for testing FF3-1. It is expected the final
34 | NIST standard will provide updated test vectors with 56-bit tweak lengths.
35 |
36 | ## Installation
37 |
38 | `pip3 install ff3`
39 |
40 | ## Usage
41 |
42 | FF3 is a Feistel cipher, and Feistel ciphers are initialized with a radix representing an alphabet. The number of
43 | characters in an alphabet is called the _radix_.
44 | The following radix values are typical:
45 |
46 | * radix 10: digits 0..9
47 | * radix 36: alphanumeric 0..9, a-z
48 | * radix 62: alphanumeric 0..9, a-z, A-Z
49 |
50 | Special characters and international character sets, such as those found in UTF-8, are supported by specifying a custom alphabet.
51 | Also, all elements in a plaintext string share the same radix. Thus, an identification number that consists of an initial letter followed
52 | by 6 digits (e.g. A123456) cannot be correctly encrypted by FPE while preserving this convention.
53 |
54 | Input plaintext has maximum length restrictions based upon the chosen radix (2 * floor(96/log2(radix))):
55 |
56 | * radix 10: 56
57 | * radix 36: 36
58 | * radix 62: 32
59 |
60 | To work around string length, its possible to encode longer text in chunks.
61 |
62 | The key length must be 128, 192, or 256 bits in length. The tweak is 7 bytes (FF3-1) or 8 bytes for the origingal FF3.
63 |
64 | As with any cryptographic package, managing and protecting the key(s) is crucial. The tweak is generally not kept secret.
65 | This package does not store the key in memory after initializing the cipher.
66 |
67 | ## Code Example
68 |
69 | The example code below uses the default domain [0-9] and can help you get started.
70 |
71 | ```python3
72 |
73 | from ff3 import FF3Cipher
74 |
75 | key = "2DE79D232DF5585D68CE47882AE256D6"
76 | tweak = "CBD09280979564"
77 | c = FF3Cipher(key, tweak)
78 |
79 | plaintext = "3992520240"
80 | ciphertext = c.encrypt(plaintext)
81 | decrypted = c.decrypt(ciphertext)
82 |
83 | print(f"{plaintext} -> {ciphertext} -> {decrypted}")
84 |
85 | # format encrypted value
86 | ccn = f"{ciphertext[:4]} {ciphertext[4:8]} {ciphertext[8:12]} {ciphertext[12:]}"
87 | print(f"Encrypted CCN value with formatting: {ccn}")
88 | ```
89 | ## CLI Example
90 |
91 | This package installs the command line scripts ff3_encrypt and ff3_decrypt which can be run
92 | from the Linux or Windows command line.
93 |
94 | ```bash
95 | % ff3_encrypt 2DE79D232DF5585D68CE47882AE256D6 CBD09280979564 3992520240
96 | 8901801106
97 | % ff3_decrypt 2DE79D232DF5585D68CE47882AE256D6 CBD09280979564 8901801106
98 | 3992520240
99 |
100 | ```
101 |
102 |
103 | ## Custom alphabets
104 |
105 | Custom alphabets up to 256 characters are supported. To use an alphabet consisting of the uppercase letters A-F (radix=6), we can continue
106 | from the above code example with:
107 |
108 | ```python3
109 | c6 = FF3Cipher.withCustomAlphabet(key, tweak, "ABCDEF")
110 | plaintext = "BADDCAFE"
111 | ciphertext = c6.encrypt(plaintext)
112 | decrypted = c6.decrypt(ciphertext)
113 |
114 | print(f"{plaintext} -> {ciphertext} -> {decrypted}")
115 | ```
116 | ## Requires
117 |
118 | This project was built and tested with Python 3.9 and later versions. The only dependency is [PyCryptodome](https://pycryptodome.readthedocs.io).
119 |
120 | ## Testing
121 |
122 | Official [test vectors](https://csrc.nist.gov/csrc/media/projects/cryptographic-standards-and-guidelines/documents/examples/ff3samples.pdf) for FF3 provided by NIST,
123 | are used for testing in this package. Also included are draft ACVP test vectors with 56-bit tweaks.
124 |
125 | To run unit tests on this implementation, including all test vectors from the NIST specification, run the command:
126 |
127 | ```bash
128 | python3 -m ff3.ff3_test
129 | ```
130 |
131 | ## Performance Benchmarks
132 |
133 | The Mysto FF3 was benchmarked on a MacBook Air (1.1 GHz Quad-Core Intel Core i5)
134 | performing 70,000 tokenization per second with random 8 character data input.
135 |
136 | To run the performance tests:
137 |
138 | ```bash
139 | python3 -m ff3.ff3_perf
140 | ```
141 |
142 | ## The FF3 Algorithm
143 |
144 | The FF3 algorithm is a tweakable block cipher based on an eight round Feistel cipher. A block cipher operates on fixed-length groups of bits, called blocks. A Feistel Cipher is not a specific cipher,
145 | but a design model. This FF3 Feistel encryption consisting of eight rounds of processing
146 | the plaintext. Each round applies an internal function or _round function_, followed by transformation steps.
147 |
148 | The FF3 round function uses AES encryption in ECB mode, which is performed each iteration
149 | on alternating halves of the text being encrypted. The *key* value is used only to initialize the AES cipher. Thereafter
150 | the *tweak* is used together with the intermediate encrypted text as input to the round function.
151 |
152 | ## Other FPE Algorithms
153 |
154 | Only FF1 and FF3 have been approved by NIST for format preserving encryption. There are patent claims on FF1 which allegedly include open source implementations. Given the issues raised in ["The Curse of Small Domains: New Attacks on Format-Preserving Encryption"](https://eprint.iacr.org/2018/556.pdf) by Hoang, Tessaro and Trieu in 2018, it is prudent to be very cautious about using any FPE that isn't a standard and hasn't stood up to public scrutiny.
155 |
156 | ## Implementation Notes
157 |
158 | This implementation was originally based upon the [Capital One Go implementation](https://github.com/capitalone/fpe). It follows the algorithm as outlined in the NIST specification as closely as possible, including naming.
159 |
160 | FPE can be used for data tokenization of sensitive data which is cryptographically reversible. This implementation does not provide any guarantees regarding PCI DSS or other validation.
161 |
162 | While all NIST and ACVP test vectors pass, this package has not otherwise been extensively tested.
163 |
164 | The cryptographic library used is [PyCryptodome](https://pypi.org/project/pycryptodome/) for AES encryption. FF3 uses a single-block with an IV of 0, which is effectively ECB mode. AES ECB is the only block cipher function which matches the requirement of the FF3 spec.
165 |
166 | The domain size was revised in FF3-1 to radixminLen >= 1,000,000 and is represented by the constant `DOMAIN_MIN` in `ff3.py`. FF3-1 is in draft status.
167 |
168 | The tweak is required in the initial `FF3Cipher` constructor, but can optionally be overridden in each `encrypt` and `decrypt` call. This is similar to passing an IV or nonce when creating an encrypter object.
169 |
170 | ## Developer Installation
171 |
172 | Use `python -m build` to build the project.
173 |
174 | To install the development version:
175 |
176 | ```bash
177 | git clone https://github.com/mysto/python-fpe.git
178 | cd python-fpe
179 | pip3 install --editable .
180 | ```
181 |
182 | Before contributing any pull requests, you will need to first fork this repository.
183 |
184 | ## Author
185 |
186 | Brad Schoening
187 |
188 | ## License
189 |
190 | This project is licensed under the terms of the [Apache 2.0 license](https://www.apache.org/licenses/LICENSE-2.0).
191 |
--------------------------------------------------------------------------------
/ff3/__init__.py:
--------------------------------------------------------------------------------
1 | from ff3.ff3 import FF3Cipher, calculate_p, encode_int_r, decode_int_r
2 | from ff3.ff3 import reverse_string
3 |
--------------------------------------------------------------------------------
/ff3/__main__.py:
--------------------------------------------------------------------------------
1 | import sys
2 | from ff3 import FF3Cipher
3 |
4 | def encrypt():
5 | c = FF3Cipher(sys.argv[1], sys.argv[2])
6 | print(c.encrypt(sys.argv[3]))
7 |
8 | def decrypt():
9 | c = FF3Cipher(sys.argv[1], sys.argv[2])
10 | print(c.decrypt(sys.argv[3]))
11 |
12 | #if __name__ == '__main__':
13 | # sys.exit(main())
14 |
--------------------------------------------------------------------------------
/ff3/ff3.py:
--------------------------------------------------------------------------------
1 | """
2 |
3 | SPDX-Copyright: Copyright (c) Schoening Consulting, LLC
4 | SPDX-License-Identifier: Apache-2.0
5 | Copyright 2021 Schoening Consulting, LLC
6 |
7 | Licensed under the Apache License, Version 2.0 (the "License");
8 | you may not use this file except in compliance with the License.
9 | You may obtain a copy of the License at
10 |
11 | http://www.apache.org/licenses/LICENSE-2.0
12 |
13 | Unless required by applicable law or agreed to in writing, software
14 | distributed under the License is distributed on an "AS IS" BASIS,
15 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16 | See the License for the specific language governing permissions and limitations under
17 | the License.
18 |
19 | """
20 |
21 | # Package ff3 implements the FF3-1 format-preserving encryption algorithm/scheme
22 |
23 | import logging
24 | import math
25 | from Crypto.Cipher import AES
26 | import string
27 |
28 | # The recommendation in Draft SP 800-38G was strengthened to a requirement in Draft
29 | # SP 800-38G Revision 1: the minimum domain size for FF1 and FF3-1 is one million.
30 |
31 | NUM_ROUNDS = 8
32 | BLOCK_SIZE = 16 # aes.BlockSize
33 | TWEAK_LEN = 8 # Original FF3 tweak length
34 | TWEAK_LEN_NEW = 7 # FF3-1 tweak length
35 | HALF_TWEAK_LEN = TWEAK_LEN // 2
36 |
37 | logger = logging.getLogger(__name__)
38 |
39 | def reverse_string(txt):
40 | """func defined for clarity"""
41 | return txt[::-1]
42 |
43 |
44 | """
45 | FF3 encodes a string within a range of minLen..maxLen. The spec uses an alternating
46 | Feistel with the following parameters:
47 | A fixed 128 bit block size
48 | 128, 192 or 256 bit key length
49 | Cipher Block Chain (CBC-MAC) round function
50 | 64-bit (FF3) or 56-bit (FF3-1)tweak
51 | eight (8) rounds
52 | Modulo addition
53 |
54 | An encoded string representation of x is in the given integer base, which must be at
55 | least 2. The result uses the lower-case letters 'a' to 'z' for digit values 10 to 35
56 | and upper-case letters 'A' to 'Z' for digit values 36 to 61.
57 |
58 | Instead of specifying the base, an alphabet may be specified as a string of unique
59 | characters. For bases larger than 62, an explicit alphabet is mandatory.
60 |
61 | FF3Cipher initializes a new FF3 Cipher object for encryption or decryption with key,
62 | tweak and radix parameters. The default radix is 10, supporting encryption of decimal
63 | numbers.
64 |
65 | AES ECB is used as the cipher round value for XORing. ECB has a block size of 128 bits
66 | (i.e 16 bytes) and is padded with zeros for blocks smaller than this size. ECB is used
67 | only in encrypt mode to generate this XOR value. A Feistel decryption uses the same ECB
68 | encrypt value to decrypt the text. XOR is trivially invertible when you know two of the
69 | arguments.
70 | """
71 |
72 |
73 | class FF3Cipher:
74 | """Class FF3Cipher implements the FF3 format-preserving encryption algorithm.
75 |
76 | If a value of radix between 2 and 62 is specified, then that many characters
77 | from the base 62 alphabet (digits + lowercase + uppercase latin) are used.
78 | """
79 | DOMAIN_MIN = 1_000_000 # 1M required in FF3-1
80 | BASE62 = string.digits + string.ascii_lowercase + string.ascii_uppercase
81 | BASE62_LEN = len(BASE62)
82 | RADIX_MAX = 256 # Support 8-bit alphabets for now
83 |
84 | def __init__(self, key, tweak, radix=10, ):
85 | keybytes = bytes.fromhex(key)
86 | self.tweak = tweak
87 | self.radix = radix
88 | if radix <= FF3Cipher.BASE62_LEN:
89 | self.alphabet = FF3Cipher.BASE62[0:radix]
90 | else:
91 | self.alphabet = None
92 |
93 | # logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s')
94 |
95 | # Calculate range of supported message lengths [minLen..maxLen]
96 | # per revised spec, radix^minLength >= 1,000,000.
97 | self.minLen = math.ceil(math.log(FF3Cipher.DOMAIN_MIN) / math.log(radix))
98 |
99 | # We simplify the specs log[radix](2^96) to 96/log2(radix) using the log base
100 | # change rule
101 | self.maxLen = 2 * math.floor(96/math.log2(radix))
102 | klen = len(keybytes)
103 |
104 | # Check if the key is 128, 192, or 256 bits = 16, 24, or 32 bytes
105 | if klen not in (16, 24, 32):
106 | raise ValueError(f'key length is {klen} but must be 128, 192, or 256 bits')
107 |
108 | # While FF3 allows radices in [2, 2^16], commonly useful range is 2..62
109 | if (radix < 2) or (radix > FF3Cipher.RADIX_MAX):
110 | raise ValueError("radix must be between 2 and 62, inclusive")
111 |
112 | # Make sure 2 <= minLength <= maxLength
113 | if (self.minLen < 2) or (self.maxLen < self.minLen):
114 | raise ValueError("minLen or maxLen invalid, adjust your radix")
115 |
116 | # AES block cipher in ECB mode with the block size derived based on the length
117 | # of the key. Always use the reversed key since Encrypt and Decrypt call ciph
118 | # expecting that
119 |
120 | self.aesCipher = AES.new(reverse_string(keybytes), AES.MODE_ECB)
121 |
122 | # factory method to create a FF3Cipher object with a custom alphabet
123 | @staticmethod
124 | def withCustomAlphabet(key, tweak, alphabet):
125 | c = FF3Cipher(key, tweak, len(alphabet))
126 | c.alphabet = alphabet
127 | return c
128 |
129 | def encrypt(self, plaintext):
130 | """Encrypts the plaintext string and returns a ciphertext of the same length
131 | and format"""
132 | return self.encrypt_with_tweak(plaintext, self.tweak)
133 |
134 | """
135 | Feistel structure
136 |
137 | u length | v length
138 | A block | B block
139 |
140 | C <- modulo function
141 |
142 | B' <- C | A' <- B
143 |
144 |
145 | Steps:
146 |
147 | Let u = [n/2]
148 | Let v = n - u
149 | Let A = X[1..u]
150 | Let B = X[u+1,n]
151 | Let T(L) = T[0..31] and T(R) = T[32..63]
152 | for i <- 0..7 do
153 | If is even, let m = u and W = T(R) Else let m = v and W = T(L)
154 | Let P = REV([NUM(Rev(B))]^12 || W ⊗ REV(i^4)
155 | Let Y = CIPH(P)
156 | Let y = NUM<2>(REV(Y))
157 | Let c = (NUM(REV(A)) + y) mod radix^m
158 | Let C = REV(STR^m(c))
159 | Let A = B
160 | Let B = C
161 | end for
162 | Return A || B
163 |
164 | * Where REV(X) reverses the order of characters in the character string X
165 |
166 | See spec and examples:
167 |
168 | https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-38Gr1-draft.pdf
169 | https://csrc.nist.gov/CSRC/media/Projects/Cryptographic-Standards-and-Guidelines/documents/examples/FF3samples.pdf
170 | """
171 |
172 | # EncryptWithTweak allows a parameter tweak instead of the current Cipher's tweak
173 |
174 | def encrypt_with_tweak(self, plaintext, tweak):
175 | """Encrypts the plaintext string and returns a ciphertext of the same length
176 | and format"""
177 | tweakBytes = bytes.fromhex(tweak)
178 |
179 | n = len(plaintext)
180 |
181 | # Check if message length is within minLength and maxLength bounds
182 | if (n < self.minLen) or (n > self.maxLen):
183 | raise ValueError(f"message length {n} is not within min {self.minLen} and "
184 | f"max {self.maxLen} bounds")
185 |
186 | # Make sure the given the length of tweak in bits is 56 or 64
187 | if len(tweakBytes) not in [TWEAK_LEN, TWEAK_LEN_NEW]:
188 | raise ValueError(f"tweak length {len(tweakBytes)} invalid: tweak must be 56"
189 | f" or 64 bits")
190 |
191 | # Todo: Check message is in current radix
192 | # Calculate split point
193 | u = math.ceil(n / 2)
194 | v = n - u
195 |
196 | # Split the message
197 | A = plaintext[:u]
198 | B = plaintext[u:]
199 |
200 | if len(tweakBytes) == TWEAK_LEN_NEW:
201 | # FF3-1
202 | tweakBytes = calculate_tweak64_ff3_1(tweakBytes)
203 |
204 | Tl = tweakBytes[:HALF_TWEAK_LEN]
205 | Tr = tweakBytes[HALF_TWEAK_LEN:]
206 | logger.debug(f"Tweak: {tweak}, tweakBytes:{tweakBytes.hex()}")
207 |
208 | # Pre-calculate the modulus since it's only one of 2 values,
209 | # depending on whether i is even or odd
210 |
211 | modU = self.radix ** u
212 | modV = self.radix ** v
213 | logger.debug(f"modU: {modU} modV: {modV}")
214 |
215 | # Main Feistel Round, 8 times
216 | #
217 | # AES ECB requires the number of bits in the plaintext to be a multiple of
218 | # the block size. Thus, we pad the input to 16 bytes
219 |
220 | for i in range(NUM_ROUNDS):
221 | # logger.debug(f"-------- Round {i}")
222 | # Determine alternating Feistel round side
223 | if i % 2 == 0:
224 | m = u
225 | W = Tr
226 | else:
227 | m = v
228 | W = Tl
229 |
230 | # P is fixed-length 16 bytes
231 | P = calculate_p(i, self.alphabet, W, B)
232 | revP = reverse_string(P)
233 |
234 | S = self.aesCipher.encrypt(bytes(revP))
235 |
236 | S = reverse_string(S)
237 | # logger.debug(f"S: {S.hex()}")
238 |
239 | y = int.from_bytes(S, byteorder='big')
240 |
241 | # Calculate c
242 | c = decode_int_r(A, self.alphabet)
243 |
244 | c = c + y
245 |
246 | if i % 2 == 0:
247 | c = c % modU
248 | else:
249 | c = c % modV
250 |
251 | # logger.debug(f"m: {m} A: {A} c: {c} y: {y}")
252 | C = encode_int_r(c, self.alphabet, int(m))
253 |
254 | # Final steps
255 | A = B
256 | B = C
257 |
258 | # logger.debug(f"A: {A} B: {B}")
259 |
260 | return A + B
261 |
262 | def decrypt(self, ciphertext):
263 | """
264 | Decrypts the ciphertext string and returns a plaintext of the same length
265 | and format.
266 |
267 | The process of decryption is essentially the same as the encryption process.
268 | The differences are (1) the addition function is replaced by a
269 | subtraction function that is its inverse, and (2) the order of the round
270 | indices (i) is reversed.
271 | """
272 | return self.decrypt_with_tweak(ciphertext, self.tweak)
273 |
274 | def decrypt_with_tweak(self, ciphertext, tweak):
275 | """Decrypts the ciphertext string and returns a plaintext of the same length
276 | and format"""
277 | tweakBytes = bytes.fromhex(tweak)
278 |
279 | n = len(ciphertext)
280 |
281 | # Check if message length is within minLength and maxLength bounds
282 | if (n < self.minLen) or (n > self.maxLen):
283 | raise ValueError(f"message length {n} is not within min {self.minLen} and "
284 | f"max {self.maxLen} bounds")
285 |
286 | # Make sure the given the length of tweak in bits is 56 or 64
287 | if len(tweakBytes) not in [TWEAK_LEN, TWEAK_LEN_NEW]:
288 | raise ValueError(f"tweak length {len(tweakBytes)} invalid: tweak must be 8 "
289 | f"bytes, or 64 bits")
290 |
291 | # Todo: Check message is in current radix
292 |
293 | # Calculate split point
294 | u = math.ceil(n/2)
295 | v = n - u
296 |
297 | # Split the message
298 | A = ciphertext[:u]
299 | B = ciphertext[u:]
300 |
301 | if len(tweakBytes) == TWEAK_LEN_NEW:
302 | # FF3-1
303 | tweakBytes = calculate_tweak64_ff3_1(tweakBytes)
304 |
305 | Tl = tweakBytes[:HALF_TWEAK_LEN]
306 | Tr = tweakBytes[HALF_TWEAK_LEN:]
307 | logger.debug(f"Tweak: {tweak}, tweakBytes:{tweakBytes.hex()}")
308 |
309 | # Pre-calculate the modulus since it's only one of 2 values,
310 | # depending on whether i is even or odd
311 |
312 | modU = self.radix ** u
313 | modV = self.radix ** v
314 | logger.debug(f"modU: {modU} modV: {modV}")
315 |
316 | # Main Feistel Round, 8 times
317 |
318 | for i in reversed(range(NUM_ROUNDS)):
319 |
320 | # logger.debug(f"-------- Round {i}")
321 | # Determine alternating Feistel round side
322 | if i % 2 == 0:
323 | m = u
324 | W = Tr
325 | else:
326 | m = v
327 | W = Tl
328 |
329 | # P is fixed-length 16 bytes
330 | P = calculate_p(i, self.alphabet, W, A)
331 | revP = reverse_string(P)
332 |
333 | S = self.aesCipher.encrypt(bytes(revP))
334 | S = reverse_string(S)
335 |
336 | # logger.debug("S: ", S.hex())
337 |
338 | y = int.from_bytes(S, byteorder='big')
339 |
340 | # Calculate c
341 | c = decode_int_r(B, self.alphabet)
342 |
343 | c = c - y
344 |
345 | if i % 2 == 0:
346 | c = c % modU
347 | else:
348 | c = c % modV
349 |
350 | # logger.debug(f"m: {m} B: {B} c: {c} y: {y}")
351 | C = encode_int_r(c, self.alphabet, int(m))
352 |
353 | # Final steps
354 | B = A
355 | A = C
356 |
357 | # logger.debug(f"A: {A} B: {B}")
358 |
359 | return A + B
360 |
361 | def calculate_p(i, alphabet, W, B):
362 | # P is always 16 bytes
363 | P = bytearray(BLOCK_SIZE)
364 |
365 | # Calculate P by XORing W, i into the first 4 bytes of P
366 | # i only requires 1 byte, rest are 0 padding bytes
367 | # Anything XOR 0 is itself, so only need to XOR the last byte
368 |
369 | P[0] = W[0]
370 | P[1] = W[1]
371 | P[2] = W[2]
372 | P[3] = W[3] ^ int(i)
373 |
374 | # The remaining 12 bytes of P are for rev(B) with padding
375 |
376 | val = decode_int_r(B, alphabet)
377 | BBytes = val.to_bytes(12, "big")
378 | # logger.debug(f"B: {B} val: {val} BBytes: {BBytes.hex()}")
379 |
380 | P[BLOCK_SIZE - len(BBytes):] = BBytes
381 | # logger.debug(f"[round: {i}] P: {P.hex()} W: {W.hex()} ")
382 | return P
383 |
384 | def calculate_tweak64_ff3_1(tweak56):
385 | tweak64 = bytearray(8)
386 | tweak64[0] = tweak56[0]
387 | tweak64[1] = tweak56[1]
388 | tweak64[2] = tweak56[2]
389 | tweak64[3] = (tweak56[3] & 0xF0)
390 | tweak64[4] = tweak56[4]
391 | tweak64[5] = tweak56[5]
392 | tweak64[6] = tweak56[6]
393 | tweak64[7] = ((tweak56[3] & 0x0F) << 4)
394 | return tweak64
395 |
396 |
397 | def encode_int_r(n, alphabet, length=0) -> str:
398 | """
399 | Return a string representation of a number in the given base system for 2..62
400 |
401 | The string is left in a reversed order expected by the calling cryptographic
402 | function
403 |
404 | examples:
405 | encode_int_r(10, hexdigits)
406 | 'A'
407 | """
408 | base = len(alphabet)
409 | if (base > FF3Cipher.RADIX_MAX):
410 | raise ValueError(f"Base {base} is outside range of supported radix "
411 | f"2..{FF3Cipher.RADIX_MAX}")
412 |
413 | x = ''
414 | while n >= base:
415 | n, b = divmod(n, base)
416 | x += alphabet[b]
417 | x += alphabet[n]
418 |
419 | if len(x) < length:
420 | x = x.ljust(length, alphabet[0])
421 |
422 | return x
423 |
424 |
425 | def decode_int_r(astring, alphabet) -> int:
426 | """Decode a Base X encoded string into the number
427 |
428 | Returns an integer
429 |
430 | Arguments:
431 | - `astring`: The encoded string
432 | - `alphabet`: The alphabet to use for decoding
433 | """
434 | strlen = len(astring)
435 | base = len(alphabet)
436 | num = 0
437 |
438 | idx = 0
439 | try:
440 | for char in reversed(astring):
441 | power = (strlen - (idx + 1))
442 | num += alphabet.index(char) * (base ** power)
443 | idx += 1
444 | except ValueError:
445 | raise ValueError(f'char {char} not found in alphabet {alphabet}')
446 |
447 | return num
448 |
--------------------------------------------------------------------------------
/ff3/ff3_perf.py:
--------------------------------------------------------------------------------
1 | from ff3 import FF3Cipher
2 | import random
3 | import string
4 | import time
5 |
6 |
7 | def timeit(f):
8 | def timed(*args, **kw):
9 | ts = time.time()
10 | result = f(*args, **kw)
11 | te = time.time()
12 |
13 | print(f'func:{f.__name__} took: {te - ts:2.4f}')
14 | return result
15 |
16 | return timed
17 |
18 |
19 | @timeit
20 | def test_encrypt(plaintexts):
21 | key = "EF4359D8D580AA4F7F036D6F04FC6A94"
22 | tweak = "D8E7920AFA330A73"
23 | for pt in plaintexts:
24 | c = FF3Cipher(key, tweak, 62)
25 | c.encrypt(pt)
26 |
27 |
28 | def test_performance(runs=100_000):
29 | plaintexts = []
30 | for i in range(runs):
31 | plaintexts.append(''.join(random.choices(string.ascii_uppercase +
32 | string.digits, k=8)))
33 | test_encrypt(plaintexts)
34 |
35 |
36 | if __name__ == '__main__':
37 | test_performance()
38 |
--------------------------------------------------------------------------------
/ff3/ff3_test.py:
--------------------------------------------------------------------------------
1 | """
2 |
3 | SPDX-Copyright: Copyright (c) Schoening Consulting, LLC
4 | SPDX-License-Identifier: Apache-2.0
5 | Copyright 2021 Schoening Consulting, LLC
6 |
7 | Licensed under the Apache License, Version 2.0 (the "License");
8 | you may not use this file except in compliance with the License.
9 | You may obtain a copy of the License at
10 |
11 | http://www.apache.org/licenses/LICENSE-2.0
12 |
13 | Unless required by applicable law or agreed to in writing, software
14 | distributed under the License is distributed on an "AS IS" BASIS,
15 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16 | See the License for the specific language governing permissions and limitations
17 | under the License.
18 |
19 | """
20 | import string
21 | import unittest
22 |
23 | from Crypto.Cipher import AES
24 |
25 | from ff3 import FF3Cipher, calculate_p, encode_int_r, decode_int_r
26 | from ff3 import reverse_string
27 |
28 | # Test vectors taken from here:
29 | # http://csrc.nist.gov/groups/ST/toolkit/documents/Examples/FF3samples.pdf
30 |
31 |
32 | testVectors = [
33 | # AES-128
34 | {
35 | "radix": 10,
36 | "key": "EF4359D8D580AA4F7F036D6F04FC6A94",
37 | "tweak": "D8E7920AFA330A73",
38 | "plaintext": "890121234567890000",
39 | "ciphertext": "750918814058654607"
40 | },
41 | {
42 | "radix": 10,
43 | "key": "EF4359D8D580AA4F7F036D6F04FC6A94",
44 | "tweak": "9A768A92F60E12D8",
45 | "plaintext": "890121234567890000",
46 | "ciphertext": "018989839189395384",
47 | },
48 | {
49 | "radix": 10,
50 | "key": "EF4359D8D580AA4F7F036D6F04FC6A94",
51 | "tweak": "D8E7920AFA330A73",
52 | "plaintext": "89012123456789000000789000000",
53 | "ciphertext": "48598367162252569629397416226",
54 | },
55 | {
56 | "radix": 10,
57 | "key": "EF4359D8D580AA4F7F036D6F04FC6A94",
58 | "tweak": "0000000000000000",
59 | "plaintext": "89012123456789000000789000000",
60 | "ciphertext": "34695224821734535122613701434",
61 | },
62 | {
63 | "radix": 26,
64 | "key": "EF4359D8D580AA4F7F036D6F04FC6A94",
65 | "tweak": "9A768A92F60E12D8",
66 | "plaintext": "0123456789abcdefghi",
67 | "ciphertext": "g2pk40i992fn20cjakb",
68 | },
69 |
70 | # AES - 192
71 | {
72 | "radix": 10,
73 | "key": "EF4359D8D580AA4F7F036D6F04FC6A942B7E151628AED2A6",
74 | "tweak": "D8E7920AFA330A73",
75 | "plaintext": "890121234567890000",
76 | "ciphertext": "646965393875028755",
77 | },
78 | {
79 | "radix": 10,
80 | "key": "EF4359D8D580AA4F7F036D6F04FC6A942B7E151628AED2A6",
81 | "tweak": "9A768A92F60E12D8",
82 | "plaintext": "890121234567890000",
83 | "ciphertext": "961610514491424446",
84 | },
85 | {
86 | "radix": 10,
87 | "key": "EF4359D8D580AA4F7F036D6F04FC6A942B7E151628AED2A6",
88 | "tweak": "D8E7920AFA330A73",
89 | "plaintext": "89012123456789000000789000000",
90 | "ciphertext": "53048884065350204541786380807",
91 | },
92 | {
93 | "radix": 10,
94 | "key": "EF4359D8D580AA4F7F036D6F04FC6A942B7E151628AED2A6",
95 | "tweak": "0000000000000000",
96 | "plaintext": "89012123456789000000789000000",
97 | "ciphertext": "98083802678820389295041483512",
98 | },
99 | {
100 | "radix": 26,
101 | "key": "EF4359D8D580AA4F7F036D6F04FC6A942B7E151628AED2A6",
102 | "tweak": "9A768A92F60E12D8",
103 | "plaintext": "0123456789abcdefghi",
104 | "ciphertext": "i0ihe2jfj7a9opf9p88",
105 | },
106 |
107 | # AES - 256
108 | {
109 | "radix": 10,
110 | "key": "EF4359D8D580AA4F7F036D6F04FC6A942B7E151628AED2A6ABF7158809CF4F3C",
111 | "tweak": "D8E7920AFA330A73",
112 | "plaintext": "890121234567890000",
113 | "ciphertext": "922011205562777495",
114 | },
115 | {
116 | "radix": 10,
117 | "key": "EF4359D8D580AA4F7F036D6F04FC6A942B7E151628AED2A6ABF7158809CF4F3C",
118 | "tweak": "9A768A92F60E12D8",
119 | "plaintext": "890121234567890000",
120 | "ciphertext": "504149865578056140",
121 | },
122 | {
123 | "radix": 10,
124 | "key": "EF4359D8D580AA4F7F036D6F04FC6A942B7E151628AED2A6ABF7158809CF4F3C",
125 | "tweak": "D8E7920AFA330A73",
126 | "plaintext": "89012123456789000000789000000",
127 | "ciphertext": "04344343235792599165734622699",
128 | },
129 | {
130 | "radix": 10,
131 | "key": "EF4359D8D580AA4F7F036D6F04FC6A942B7E151628AED2A6ABF7158809CF4F3C",
132 | "tweak": "0000000000000000",
133 | "plaintext": "89012123456789000000789000000",
134 | "ciphertext": "30859239999374053872365555822",
135 | },
136 | {
137 | "radix": 26,
138 | "key": "EF4359D8D580AA4F7F036D6F04FC6A942B7E151628AED2A6ABF7158809CF4F3C",
139 | "tweak": "9A768A92F60E12D8",
140 | "plaintext": "0123456789abcdefghi",
141 | "ciphertext": "p0b2godfja9bhb7bk38",
142 | }
143 | ]
144 |
145 | # ACVP vectors for FF3-1 using 56-bit tweaks from private communication updating:
146 | # https://pages.nist.gov/ACVP/draft-celi-acvp-symmetric.html#name-test-groups
147 |
148 | testVectors_ACVP_AES_FF3_1 = [
149 | # AES - 128
150 | {
151 | # tg: 1 tc: 1
152 | "radix": 10,
153 | "alphabet": "0123456789",
154 | "key": "2DE79D232DF5585D68CE47882AE256D6",
155 | "tweak": "CBD09280979564",
156 | "plaintext": "3992520240",
157 | "ciphertext": "8901801106"
158 | },
159 | {
160 | # tg: 1 tc: 1
161 | "radix": 10,
162 | "alphabet": "0123456789",
163 | "key": "01C63017111438F7FC8E24EB16C71AB5",
164 | "tweak": "C4E822DCD09F27",
165 | "plaintext": "60761757463116869318437658042297305934914824457484538562",
166 | "ciphertext": "35637144092473838892796702739628394376915177448290847293"
167 | },
168 | {
169 | # tg: 2 tc: 26
170 | "radix": 26,
171 | "alphabet": "abcdefghijklmnopqrstuvwxyz",
172 | "key": "718385E6542534604419E83CE387A437",
173 | "tweak": "B6F35084FA90E1",
174 | "plaintext": "wfmwlrorcd",
175 | "ciphertext": "ywowehycyd"
176 | },
177 | {
178 | # tg: 2 tc: 27
179 | "radix": 26,
180 | "alphabet": "abcdefghijklmnopqrstuvwxyz",
181 | "key": "DB602DFF22ED7E84C8D8C865A941A238",
182 | "tweak": "EBEFD63BCC2083",
183 | "plaintext": "kkuomenbzqvggfbteqdyanwpmhzdmoicekiihkrm",
184 | "ciphertext": "belcfahcwwytwrckieymthabgjjfkxtxauipmjja"
185 | },
186 | {
187 | # tg: 3 tc: 51
188 | "radix": 64,
189 | "alphabet": "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz+/",
190 | "key": "AEE87D0D485B3AFD12BD1E0B9D03D50D",
191 | "tweak": "5F9140601D224B",
192 | "plaintext": "ixvuuIHr0e",
193 | "ciphertext": "GR90R1q838"
194 | },
195 | {
196 | # tg: 3 tc: 52
197 | "radix": 64,
198 | "alphabet": "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz+/",
199 | "key": "7B6C88324732F7F4AD435DA9AD77F917",
200 | "tweak": "3F42102C0BAB39",
201 | "plaintext": "21q1kbbIVSrAFtdFWzdMeIDpRqpo",
202 | "ciphertext": "cvQ/4aGUV4wRnyO3CHmgEKW5hk8H"
203 | },
204 | # AES - 192
205 | {
206 | # tg: 4 tc: 76
207 | "radix": 10,
208 | "alphabet": "0123456789",
209 | "key": "F62EDB777A671075D47563F3A1E9AC797AA706A2D8E02FC8",
210 | "tweak": "493B8451BF6716",
211 | "plaintext": "4406616808",
212 | "ciphertext": "1807744762"
213 | },
214 | {
215 | # tg: 4 tc: 77
216 | "radix": 10,
217 | "alphabet": "0123456789",
218 | "key": "0951B475D1A327C52756F2624AF224C80E9BE85F09B2D44F",
219 | "tweak": "D679E2EA3054E1",
220 | "plaintext": "99980459818278359406199791971849884432821321826358606310",
221 | "ciphertext": "84359031857952748660483617398396641079558152339419110919"
222 | },
223 | {
224 | # tg: 5 tc: 101
225 | "radix": 26,
226 | "alphabet": "abcdefghijklmnopqrstuvwxyz",
227 | "key": "49CCB8F62D941E5684599ECA0300937B5C766D053E109777",
228 | "tweak": "0BFCF75CDC2FC1",
229 | "plaintext": "jaxlrchjjx",
230 | "ciphertext": "kjdbfqyahd"
231 | },
232 | {
233 | # tg: 5 tc: 102
234 | "radix": 26,
235 | "alphabet": "abcdefghijklmnopqrstuvwxyz",
236 | "key": "03D253674A9309FF07ED0E71B24CBFE769025E09FCE544D7",
237 | "tweak": "B33176B1DA0F6C",
238 | "plaintext": "tafzrybuvhiqvcyztuxfnwfprmqlwpayphxbawpl",
239 | "ciphertext": "loaemzbgqkywkdhmncrijzildzleoqibtthdiliv"
240 | },
241 | {
242 | # tg: 6 tc: 126
243 | "radix": 64,
244 | "alphabet": "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz+/",
245 | "key": "1C24B74B7C1B9969314CB53E92F98EFD620D5520017FB076",
246 | "tweak": "0380341C425A6F",
247 | "plaintext": "6np8r2t8zo",
248 | "ciphertext": "HgpCXoA1Rt"
249 | },
250 | {
251 | # tg: 6 tc: 127
252 | "radix": 64,
253 | "alphabet": "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz+/",
254 | "key": "C0ABADFC071379824A070E8C3FD40DD9BFD7A3C99A0D5FE3",
255 | "tweak": "6C2926C705DDAF",
256 | "plaintext": "GKB6sa9g56BSJ09iJ4dsaxRdsMvo",
257 | "ciphertext": "gC0tTSdDPxM79QOWi+z+SNL9C4V+"
258 | },
259 | # AES - 256
260 | {
261 | # tg: 7 tc: 151
262 | "radix": 10,
263 | "alphabet": "0123456789",
264 | "key": "1FAA03EFF55A06F8FAB3F1DC57127D493E2F8F5C365540467A3A055BDBE6481D",
265 | "tweak": "4D67130C030445",
266 | "plaintext": "3679409436",
267 | "ciphertext": "1735794859"
268 | },
269 | {
270 | # tg: 7 tc: 152
271 | "radix": 10,
272 | "alphabet": "0123456789",
273 | "key": "9CE16E125BD422A011408EB083355E7089E70A4CD2F59E141D0B94A74BCC5967",
274 | "tweak": "4684635BD2C821",
275 | "plaintext": "85783290820098255530464619643265070052870796363685134012",
276 | "ciphertext": "75104723514036464144839960480545848044718729603261409917"
277 | },
278 | {
279 | # tg: 8 tc: 176
280 | "radix": 26,
281 | "alphabet": "abcdefghijklmnopqrstuvwxyz",
282 | "key": "6187F8BDE99F7DAF9E3EE8A8654308E7E51D31FA88AFFAEB5592041C033B736B",
283 | "tweak": "5820812B3D5DD1",
284 | "plaintext": "mkblaoiyfd",
285 | "ciphertext": "ifpyiihvvq"
286 | },
287 | {
288 | # tg: 8 tc: 177
289 | "radix": 26,
290 | "alphabet": "abcdefghijklmnopqrstuvwxyz",
291 | "key": "F6807FB9688937E4D4956006C8F0CB2394148A5F4B14666CF353F4941428FFD7",
292 | "tweak": "30C87B99890096",
293 | "plaintext": "wrammvhudopmaazlsxevzwzwpezzmghwfnmkitnk",
294 | "ciphertext": "nzftnfkliuctlmtdfrxfhwgevrbcbgljurnytxkj"
295 | },
296 | {
297 | # tg: 9 tc: 201
298 | "radix": 64,
299 | "alphabet": "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz+/",
300 | "key": "9C2B69F7DDF181C54398E345BE04C2F6B00B9DD1679200E1E04C4FF961AE0F09",
301 | "tweak": "103C238B4B1E44",
302 | "plaintext": "H2/c6FblSA",
303 | "ciphertext": "EOg4H1bE+8"
304 | },
305 | {
306 | # tg: 9 tc: 202
307 | "radix": 64,
308 | "alphabet": "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz+/",
309 | "key": "C58BCBD08B90006CEC7E82B2D987D79F6A21111DEF0CEBB273CBAEB2D6CD4044",
310 | "tweak": "7036604882667B",
311 | "plaintext": "bz5TcS1krnD8IOLdrQeKzXkLAa6h",
312 | "ciphertext": "Z6x3/9LPW8SZunRezRM8J68Q4J03"
313 | },
314 | ]
315 |
316 |
317 | class TestFF3(unittest.TestCase):
318 |
319 | def test_encode_int(self):
320 | hexdigits = "0123456789abcdef"
321 | self.assertEqual(reverse_string(encode_int_r(5, "01")), '101')
322 | self.assertEqual(reverse_string(encode_int_r(6, "01234")), '11')
323 | self.assertEqual(reverse_string(encode_int_r(7, "01234", 5)), '00012')
324 | self.assertEqual(reverse_string(encode_int_r(7, "abcde", 5)), 'aaabc')
325 | self.assertEqual(reverse_string(encode_int_r(10, hexdigits)), 'a')
326 | self.assertEqual(reverse_string(encode_int_r(32, hexdigits)), '20')
327 |
328 | def test_decode_int(self):
329 | hexdigits = "0123456789abcdef"
330 | self.assertEqual(321, (decode_int_r("123", string.digits)))
331 | self.assertEqual(101, (decode_int_r("101", string.digits)))
332 | self.assertEqual(0x02, (decode_int_r("20", hexdigits)))
333 | self.assertEqual(0xAA, (decode_int_r("aa", hexdigits)))
334 | self.assertEqual(44831223490454933899171349985, decode_int_r("p0oModYgk00ZI4S6", "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"))
335 |
336 | def test_aes_ecb(self):
337 | # NIST test vector for ECB-AES128
338 | key = bytes.fromhex('2b7e151628aed2a6abf7158809cf4f3c')
339 | pt = bytes.fromhex('6bc1bee22e409f96e93d7e117393172a')
340 | c = AES.new(key, AES.MODE_ECB)
341 | ct = c.encrypt(pt)
342 | self.assertEqual(ct.hex(), '3ad77bb40d7a3660a89ecaf32466ef97')
343 |
344 | def test_calculateP(self):
345 | # NIST Sample # 1, round 0
346 | i = 0
347 | alphabet = string.digits
348 | b = "567890000"
349 | w = bytes.fromhex("FA330A73")
350 | p = calculate_p(i, alphabet, w, b)
351 | self.assertEqual(p,
352 | bytes([250, 51, 10, 115, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 129, 205]))
353 |
354 | def test_encrypt_boundaries(self):
355 | c = FF3Cipher("EF4359D8D580AA4F7F036D6F04FC6A94", "D8E7920AFA330A73")
356 | # test max length 56 digit string with default radix 10
357 | plaintext = "12345678901234567890123456789012345678901234567890123456"
358 | ct = c.encrypt(plaintext)
359 | pt = c.decrypt(ct)
360 | self.assertEqual(plaintext, pt)
361 | # test max length 40 alphanumeric string with radix 26
362 | c = FF3Cipher("EF4359D8D580AA4F7F036D6F04FC6A94", "D8E7920AFA330A73", 26)
363 | plaintext = "0123456789abcdefghijklmn"
364 | ct = c.encrypt(plaintext)
365 | pt = c.decrypt(ct)
366 | self.assertEqual(plaintext, pt)
367 | # test max length 36 alphanumeric string with radix 36
368 | c = FF3Cipher("EF4359D8D580AA4F7F036D6F04FC6A94", "D8E7920AFA330A73", 36)
369 | plaintext = "abcdefghijklmnopqrstuvwxyz0123456789"
370 | ct = c.encrypt(plaintext)
371 | pt = c.decrypt(ct)
372 | self.assertEqual(plaintext, pt)
373 |
374 | def test_encrypt_all(self):
375 | for test in testVectors:
376 | with self.subTest(testVector=test):
377 | c = FF3Cipher(test['key'], test['tweak'], test['radix'])
378 | s = c.encrypt(test['plaintext'])
379 | self.assertEqual(s, test['ciphertext'])
380 |
381 | def test_decrypt_all(self):
382 | for test in testVectors:
383 | with self.subTest(testVector=test):
384 | c = FF3Cipher(test['key'], test['tweak'], test['radix'])
385 | s = c.decrypt(test['ciphertext'])
386 | self.assertEqual(s, test['plaintext'])
387 |
388 | def test_encrypt_acvp(self):
389 | for test in testVectors_ACVP_AES_FF3_1:
390 | with self.subTest(testVector=test):
391 | c = FF3Cipher.withCustomAlphabet(test['key'], test['tweak'],
392 | test['alphabet'])
393 | s = c.encrypt(test['plaintext'])
394 | self.assertEqual(s, test['ciphertext'])
395 |
396 | def test_decrypt_acvp(self):
397 | for test in testVectors_ACVP_AES_FF3_1:
398 | with self.subTest(testVector=test):
399 | c = FF3Cipher.withCustomAlphabet(test['key'], test['tweak'],
400 | test['alphabet'])
401 | s = c.decrypt(test['ciphertext'])
402 | self.assertEqual(s, test['plaintext'])
403 |
404 | # test with 56 bit tweak
405 | def test_encrypt_tweak56(self):
406 | # 56-bit tweak
407 | tweak = "D8E7920AFA330A"
408 | ciphertext = "477064185124354662"
409 | testVector = testVectors[0]
410 | c = FF3Cipher(testVector['key'], tweak)
411 | s = c.encrypt(testVector['plaintext'])
412 | self.assertEqual(s, ciphertext)
413 | x = c.decrypt(s)
414 | self.assertEqual(x, testVector['plaintext'])
415 |
416 | # Check the first NIST 128-bit test vector using superscript characters
417 | def test_custom_alphabet(self):
418 | alphabet = "⁰¹²³⁴⁵⁶⁷⁸⁹"
419 | key = "EF4359D8D580AA4F7F036D6F04FC6A94"
420 | tweak = "D8E7920AFA330A73"
421 | plaintext = "⁸⁹⁰¹²¹²³⁴⁵⁶⁷⁸⁹⁰⁰⁰⁰"
422 | ciphertext = "⁷⁵⁰⁹¹⁸⁸¹⁴⁰⁵⁸⁶⁵⁴⁶⁰⁷"
423 | c = FF3Cipher.withCustomAlphabet(key, tweak, alphabet)
424 | s = c.encrypt(plaintext)
425 | self.assertEqual(s, ciphertext)
426 | x = c.decrypt(s)
427 | self.assertEqual(x, plaintext)
428 |
429 | def test_german(self):
430 | """
431 | Test the German alphabet with a radix of 70. German consists of the latin
432 | alphabet plus four additional letters, each of which have uppercase and
433 | lowercase letters
434 | """
435 |
436 | german_alphabet = string.digits + string.ascii_lowercase + \
437 | string.ascii_uppercase + "ÄäÖöÜüẞß"
438 | key = "EF4359D8D580AA4F7F036D6F04FC6A94"
439 | tweak = "D8E7920AFA330A73"
440 | plaintext = "liebeGrüße"
441 | ciphertext = "5kÖQbairXo"
442 | c = FF3Cipher.withCustomAlphabet(key, tweak, alphabet=german_alphabet)
443 | s = c.encrypt(plaintext)
444 | self.assertEqual(s, ciphertext)
445 | x = c.decrypt(s)
446 | self.assertEqual(x, plaintext)
447 |
448 | def test_decodeint_signbyte(self):
449 | alphabet = string.ascii_uppercase + string.ascii_lowercase + string.digits
450 | key = "2DE79D232DF5585D68CE47882AE256D6"
451 | tweak = "CBD09280979564"
452 | plaintext = "Ceciestuntestdechiffrement123cet"
453 | ciphertext = "0uaTPI9g49f9MMw54OvY8x5rmNcrhydM"
454 | c = FF3Cipher.withCustomAlphabet(key, tweak, alphabet)
455 | s = c.encrypt(plaintext)
456 | self.assertEqual(s, ciphertext)
457 | x = c.decrypt(s)
458 | self.assertEqual(x, plaintext)
459 |
460 |
461 | # Check that encryption and decryption are inverses over whole domain
462 | def test_whole_domain(self):
463 | test_cases = [
464 | # (radix, plaintext_len, alphabet (None means default))
465 | (2, 10, None),
466 | (3, 6, None),
467 | (10, 3, None),
468 | (17, 3, None),
469 | (62, 2, None),
470 | (3, 7, "ABC"),
471 | ]
472 |
473 | max_radix = max(radix for radix, plaintext_len, alphabet in test_cases)
474 |
475 | # Temporarily reduce DOMAIN_MIN to make testing fast
476 | domain_min_orig = FF3Cipher.DOMAIN_MIN
477 | FF3Cipher.DOMAIN_MIN = max_radix + 1
478 |
479 | key = "EF4359D8D580AA4F7F036D6F04FC6A94"
480 | tweak = "D8E7920AFA330A73"
481 | for radix, plaintext_len, alphabet in test_cases:
482 | if alphabet is None:
483 | c = FF3Cipher(key, tweak, radix=radix)
484 | else:
485 | c = FF3Cipher.withCustomAlphabet(key, tweak, alphabet=alphabet)
486 | self.subTest(radix=radix, plaintext_len=plaintext_len)
487 |
488 | # Integer representations of each possible plaintext
489 | plaintexts_as_ints = list(range(radix ** plaintext_len))
490 |
491 | # String representations of each possible plaintext
492 | all_possible_plaintexts = [
493 | encode_int_r(i, alphabet=c.alphabet, length=plaintext_len)
494 | for i in plaintexts_as_ints
495 | ]
496 |
497 | # Check that plaintexts decode correctly
498 | self.assertEqual(
499 | [
500 | decode_int_r(plaintext, c.alphabet)
501 | for plaintext in all_possible_plaintexts
502 | ],
503 | plaintexts_as_ints
504 | )
505 |
506 | # Check that there are no duplicate plaintexts
507 | self.assertEqual(
508 | len(set(all_possible_plaintexts)),
509 | len(all_possible_plaintexts)
510 | )
511 |
512 | # Check that all plaintexts have the expected length
513 | self.assertTrue(
514 | all(
515 | len(plaintext) == plaintext_len
516 | for plaintext in all_possible_plaintexts
517 | )
518 | )
519 |
520 | all_possible_ciphertexts = [
521 | c.encrypt(plaintext) for plaintext in all_possible_plaintexts
522 | ]
523 |
524 | # Check that encryption is format-preserving
525 | self.assertEqual(
526 | set(all_possible_plaintexts), set(all_possible_ciphertexts)
527 | )
528 |
529 | all_decrypted_ciphertexts = [
530 | c.decrypt(ciphertext) for ciphertext in all_possible_ciphertexts
531 | ]
532 |
533 | # Check that encryption and decryption are inverses
534 | self.assertEqual(all_possible_plaintexts, all_decrypted_ciphertexts)
535 |
536 | # Note: it would be mathematically redundant to also check first decrypting
537 | # and then encrypting, since permutations have only two-sided inverses.
538 |
539 | # Restore original DOMAIN_MIN value
540 | FF3Cipher.DOMAIN_MIN = domain_min_orig
541 |
542 |
543 | if __name__ == '__main__':
544 | unittest.main()
545 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | pycryptodome
2 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [metadata]
2 | name = ff3
3 | version = 1.0.2
4 | author = Schoening Consulting, LLC
5 | author_email = bschoeni+llc@gmail.com
6 | description = Format Preserving Encryption (FPE) with FF3
7 | url = https://github.com/mysto/python-fpe
8 | long_description = file: README.md
9 | long_description_content_type = text/markdown
10 | classifiers =
11 | Development Status :: 5 - Production/Stable
12 | Intended Audience :: Developers
13 | Intended Audience :: Financial and Insurance Industry
14 | Intended Audience :: Healthcare Industry
15 | Topic :: Security :: Cryptography
16 | Programming Language :: Python :: 3
17 | License :: OSI Approved :: Apache Software License
18 | Operating System :: OS Independent
19 | Programming Language :: Python
20 | Programming Language :: Python :: 3
21 | Programming Language :: Python :: 3.9
22 | Programming Language :: Python :: 3.10
23 | Programming Language :: Python :: 3.11
24 | Programming Language :: Python :: 3.12
25 | Programming Language :: Python :: 3.13
26 |
27 | [options]
28 | packages = ff3
29 | install_requires = pycryptodome
30 | python_requires = >=3.8
31 |
32 | [options.entry_points]
33 | console_scripts =
34 | ff3_encrypt = ff3.__main__:encrypt
35 | ff3_decrypt = ff3.__main__:decrypt
36 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | from setuptools import setup
2 |
3 | setup()
4 |
--------------------------------------------------------------------------------