fast-cipher 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fast_cipher-0.1.0/PKG-INFO +207 -0
- fast_cipher-0.1.0/README.md +197 -0
- fast_cipher-0.1.0/pyproject.toml +22 -0
- fast_cipher-0.1.0/src/fast_cipher/__init__.py +29 -0
- fast_cipher-0.1.0/src/fast_cipher/cipher.py +131 -0
- fast_cipher-0.1.0/src/fast_cipher/core.py +39 -0
- fast_cipher-0.1.0/src/fast_cipher/encoding.py +52 -0
- fast_cipher-0.1.0/src/fast_cipher/layers.py +55 -0
- fast_cipher-0.1.0/src/fast_cipher/params.py +146 -0
- fast_cipher-0.1.0/src/fast_cipher/prf.py +82 -0
- fast_cipher-0.1.0/src/fast_cipher/prng.py +79 -0
- fast_cipher-0.1.0/src/fast_cipher/py.typed +0 -0
- fast_cipher-0.1.0/src/fast_cipher/sbox.py +28 -0
- fast_cipher-0.1.0/src/fast_cipher/tokens/__init__.py +249 -0
- fast_cipher-0.1.0/src/fast_cipher/tokens/alphabets.py +37 -0
- fast_cipher-0.1.0/src/fast_cipher/tokens/registry.py +165 -0
- fast_cipher-0.1.0/src/fast_cipher/tokens/scanner.py +389 -0
- fast_cipher-0.1.0/src/fast_cipher/tokens/transformer.py +29 -0
- fast_cipher-0.1.0/src/fast_cipher/tokens/types.py +50 -0
- fast_cipher-0.1.0/src/fast_cipher/types.py +48 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: fast-cipher
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: FAST format-preserving encryption cipher in pure Python
|
|
5
|
+
Requires-Dist: cryptography>=46.0.5
|
|
6
|
+
Requires-Dist: pytest>=9.0.2 ; extra == 'dev'
|
|
7
|
+
Requires-Python: >=3.14
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# FAST format-preserving cipher for Python
|
|
12
|
+
|
|
13
|
+
A pure Python implementation of the [FAST cipher](https://github.com/jedisct1/fast),
|
|
14
|
+
a format-preserving encryption (FPE) scheme designed for tokenizing API keys, credentials, and other structured secrets.
|
|
15
|
+
|
|
16
|
+
For prefix-based tokens (GitHub, AWS, Stripe, etc.), encrypted output keeps the exact same format
|
|
17
|
+
(length, prefix, character set) as the originals, so they pass through systems that validate token formats.
|
|
18
|
+
Heuristic tokens (Fastly, AWS secret keys) are wrapped in a tagged marker since they have no distinguishing prefix.
|
|
19
|
+
|
|
20
|
+
Fully interoperable with the [C](https://github.com/jedisct1/c-fast),
|
|
21
|
+
[Zig](https://github.com/jedisct1/zig-fast),
|
|
22
|
+
[Go](https://github.com/jedisct1/go-fast), and
|
|
23
|
+
[JavaScript](https://github.com/nickvdyck/js-fast) implementations.
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install fast-cipher
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Or with [uv](https://docs.astral.sh/uv/):
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
uv add fast-cipher
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quick start
|
|
38
|
+
|
|
39
|
+
The most common use case is encrypting tokens and API keys found inside a block of text.
|
|
40
|
+
`TokenEncryptor` handles scanning, encrypting, and decrypting automatically:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import os
|
|
44
|
+
from fast_cipher.tokens import TokenEncryptor
|
|
45
|
+
|
|
46
|
+
key = os.urandom(32) # AES-128, AES-192, or AES-256
|
|
47
|
+
encryptor = TokenEncryptor(key)
|
|
48
|
+
|
|
49
|
+
text = """
|
|
50
|
+
Here are the credentials for the staging environment:
|
|
51
|
+
GitHub token: ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij
|
|
52
|
+
AWS access key: AKIAIOSFODNN7EXAMPLE
|
|
53
|
+
Stripe key: sk_live_ABCDEFGHIJKLMNOPQRSTUVWXab
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
encrypted = encryptor.encrypt(text)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
For prefix-based tokens the result still looks like valid tokens: same prefixes, same lengths, same
|
|
60
|
+
character sets, but the secret parts have been replaced with ciphertext. Decryption restores the original
|
|
61
|
+
text exactly:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
decrypted = encryptor.decrypt(encrypted)
|
|
65
|
+
assert decrypted == text
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Tweaks
|
|
69
|
+
|
|
70
|
+
A tweak is optional context data that gets mixed into the encryption.
|
|
71
|
+
The same plaintext encrypted with different tweaks produces different ciphertext,
|
|
72
|
+
which is useful for binding tokens to a specific user, session, or tenant:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
enc_alice = encryptor.encrypt(text, tweak=b"user-alice")
|
|
76
|
+
enc_bob = encryptor.encrypt(text, tweak=b"user-bob")
|
|
77
|
+
|
|
78
|
+
assert enc_alice != enc_bob
|
|
79
|
+
|
|
80
|
+
# Each can only be decrypted with the matching tweak
|
|
81
|
+
assert encryptor.decrypt(enc_alice, tweak=b"user-alice") == text
|
|
82
|
+
assert encryptor.decrypt(enc_bob, tweak=b"user-bob") == text
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Filtering by token type
|
|
86
|
+
|
|
87
|
+
If you only want to encrypt certain kinds of tokens and leave the rest as-is,
|
|
88
|
+
pass a `types` list with the pattern names you care about:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
text = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij and AKIAIOSFODNN7EXAMPLE"
|
|
92
|
+
|
|
93
|
+
encrypted = encryptor.encrypt(text, types=["github-pat"])
|
|
94
|
+
# The GitHub token is encrypted, but the AWS key is untouched
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Supported token types
|
|
98
|
+
|
|
99
|
+
The following patterns are detected and encrypted out of the box:
|
|
100
|
+
|
|
101
|
+
| Provider | Pattern name(s) | Prefix |
|
|
102
|
+
| -------------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------- |
|
|
103
|
+
| Anthropic | `anthropic` | `sk-ant-api03-` |
|
|
104
|
+
| AWS | `aws-access-key` | `AKIA` |
|
|
105
|
+
| Datadog | `datadog` | `ddapi_` |
|
|
106
|
+
| GitHub | `github-pat`, `github-oauth`, `github-user`, `github-server`, `github-refresh` | `ghp_`, `gho_`, `ghu_`, `ghs_`, `ghr_` |
|
|
107
|
+
| GitLab | `gitlab` | `glpat-` |
|
|
108
|
+
| Google | `google-api` | `AIza` |
|
|
109
|
+
| Grafana | `grafana` | `glc_` |
|
|
110
|
+
| HuggingFace | `huggingface` | `hf_` |
|
|
111
|
+
| npm | `npm` | `npm_` |
|
|
112
|
+
| OpenAI | `openai`, `openai-legacy` | `sk-proj-`, `sk-` |
|
|
113
|
+
| PyPI | `pypi` | `pypi-` |
|
|
114
|
+
| SendGrid | `sendgrid` | `SG.` |
|
|
115
|
+
| Slack | `slack-bot`, `slack-user` | `xoxb-`, `xoxp-` |
|
|
116
|
+
| Stripe | `stripe-secret-live`, `stripe-publish-live`, `stripe-secret-test`, `stripe-publish-test` | `sk_live_`, `pk_live_`, `sk_test_`, `pk_test_` |
|
|
117
|
+
| Supabase | `supabase` | `sbp_` |
|
|
118
|
+
| Twilio | `twilio` | `SK` |
|
|
119
|
+
| Vercel | `vercel` | `vercel_` |
|
|
120
|
+
| Fastly | `fastly` | *(heuristic, no prefix)* |
|
|
121
|
+
| AWS Secret Key | `aws-secret-key` | *(heuristic, no prefix)* |
|
|
122
|
+
|
|
123
|
+
Heuristic patterns don't rely on a prefix. They look for strings with high entropy and mixed character classes, which is how secrets like Fastly tokens and AWS secret keys are typically formatted. Because there is no distinguishing prefix, encrypted output is wrapped in an `[ENCRYPTED:<name>]` marker. `decrypt()` will attempt to unwrap anything matching that marker pattern, so avoid feeding text containing literal `[ENCRYPTED:...]` strings that were not produced by `encrypt()`.
|
|
124
|
+
|
|
125
|
+
## Custom token patterns
|
|
126
|
+
|
|
127
|
+
You can register your own patterns for tokens that aren't covered by the built-in set.
|
|
128
|
+
A `SimpleTokenPattern` works for anything that has a fixed prefix followed by a body with a known alphabet:
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
from fast_cipher.tokens import ALPHANUMERIC, TokenEncryptor
|
|
132
|
+
from fast_cipher.tokens.types import SimpleTokenPattern
|
|
133
|
+
|
|
134
|
+
my_pattern = SimpleTokenPattern(
|
|
135
|
+
name="myapp-api-key",
|
|
136
|
+
prefix="myapp_",
|
|
137
|
+
body_regex="[A-Za-z0-9]{32}",
|
|
138
|
+
body_alphabet=ALPHANUMERIC,
|
|
139
|
+
min_body_length=32,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
key = os.urandom(32)
|
|
143
|
+
encryptor = TokenEncryptor(key)
|
|
144
|
+
encryptor.register(my_pattern)
|
|
145
|
+
|
|
146
|
+
text = "key: myapp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef"
|
|
147
|
+
encrypted = encryptor.encrypt(text)
|
|
148
|
+
decrypted = encryptor.decrypt(encrypted)
|
|
149
|
+
assert decrypted == text
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Registered patterns take priority over built-in ones.
|
|
153
|
+
|
|
154
|
+
The available alphabets are `DIGITS`, `HEX_LOWER`, `ALPHANUMERIC_UPPER`, `ALPHANUMERIC_LOWER`, `ALPHANUMERIC`, `BASE64`, and `BASE64URL`.
|
|
155
|
+
You can also create your own with `Alphabet(name="my-abc", chars="abc...")`.
|
|
156
|
+
|
|
157
|
+
## Low-level cipher
|
|
158
|
+
|
|
159
|
+
`TokenEncryptor` is built on top of `FastCipher`, which you can use directly when you need format-preserving encryption for arbitrary data. It works on sequences of integers in a given radix (base).
|
|
160
|
+
|
|
161
|
+
For example, to encrypt an 8-digit decimal number:
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
from fast_cipher import FastCipher, calculate_recommended_params
|
|
165
|
+
|
|
166
|
+
params = calculate_recommended_params(radix=10, word_length=8)
|
|
167
|
+
key = os.urandom(32)
|
|
168
|
+
cipher = FastCipher(params, key)
|
|
169
|
+
|
|
170
|
+
plaintext = [1, 2, 3, 4, 5, 6, 7, 8]
|
|
171
|
+
ciphertext = cipher.encrypt(plaintext)
|
|
172
|
+
|
|
173
|
+
# Result is still 8 digits, each between 0 and 9
|
|
174
|
+
assert len(ciphertext) == 8
|
|
175
|
+
assert all(0 <= d < 10 for d in ciphertext)
|
|
176
|
+
|
|
177
|
+
decrypted = cipher.decrypt(ciphertext)
|
|
178
|
+
assert decrypted == plaintext
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
For raw bytes, use radix 256 with the `encrypt_bytes`/`decrypt_bytes` convenience methods:
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
params = calculate_recommended_params(radix=256, word_length=16)
|
|
185
|
+
cipher = FastCipher(params, key)
|
|
186
|
+
|
|
187
|
+
ciphertext = cipher.encrypt_bytes(b"sensitive data!!")
|
|
188
|
+
plaintext = cipher.decrypt_bytes(ciphertext)
|
|
189
|
+
assert plaintext == b"sensitive data!!"
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Cleanup
|
|
193
|
+
|
|
194
|
+
When you're done with an encryptor or cipher, call `destroy()` to invalidate the instance:
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
encryptor.destroy()
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
After `destroy()`, any further calls to `encrypt()` or `decrypt()` will raise a `RuntimeError`.
|
|
201
|
+
The method overwrites key references with zero-filled placeholders and clears internal state,
|
|
202
|
+
but Python's garbage collector may retain copies of the original key bytes in memory.
|
|
203
|
+
For applications that require guaranteed memory scrubbing, use a C-level implementation instead.
|
|
204
|
+
|
|
205
|
+
## Cross-implementation compatibility
|
|
206
|
+
|
|
207
|
+
Ciphertext produced by any FAST implementation (C, Zig, Go, JavaScript, Python) can be decrypted by any other, as long as the key, radix, word length, and tweak match. This library is tested against the Go test vectors and cross-validated against the JavaScript implementation.
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# FAST format-preserving cipher for Python
|
|
2
|
+
|
|
3
|
+
A pure Python implementation of the [FAST cipher](https://github.com/jedisct1/fast),
|
|
4
|
+
a format-preserving encryption (FPE) scheme designed for tokenizing API keys, credentials, and other structured secrets.
|
|
5
|
+
|
|
6
|
+
For prefix-based tokens (GitHub, AWS, Stripe, etc.), encrypted output keeps the exact same format
|
|
7
|
+
(length, prefix, character set) as the originals, so they pass through systems that validate token formats.
|
|
8
|
+
Heuristic tokens (Fastly, AWS secret keys) are wrapped in a tagged marker since they have no distinguishing prefix.
|
|
9
|
+
|
|
10
|
+
Fully interoperable with the [C](https://github.com/jedisct1/c-fast),
|
|
11
|
+
[Zig](https://github.com/jedisct1/zig-fast),
|
|
12
|
+
[Go](https://github.com/jedisct1/go-fast), and
|
|
13
|
+
[JavaScript](https://github.com/nickvdyck/js-fast) implementations.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install fast-cipher
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Or with [uv](https://docs.astral.sh/uv/):
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
uv add fast-cipher
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
The most common use case is encrypting tokens and API keys found inside a block of text.
|
|
30
|
+
`TokenEncryptor` handles scanning, encrypting, and decrypting automatically:
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import os
|
|
34
|
+
from fast_cipher.tokens import TokenEncryptor
|
|
35
|
+
|
|
36
|
+
key = os.urandom(32) # AES-128, AES-192, or AES-256
|
|
37
|
+
encryptor = TokenEncryptor(key)
|
|
38
|
+
|
|
39
|
+
text = """
|
|
40
|
+
Here are the credentials for the staging environment:
|
|
41
|
+
GitHub token: ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij
|
|
42
|
+
AWS access key: AKIAIOSFODNN7EXAMPLE
|
|
43
|
+
Stripe key: sk_live_ABCDEFGHIJKLMNOPQRSTUVWXab
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
encrypted = encryptor.encrypt(text)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
For prefix-based tokens the result still looks like valid tokens: same prefixes, same lengths, same
|
|
50
|
+
character sets, but the secret parts have been replaced with ciphertext. Decryption restores the original
|
|
51
|
+
text exactly:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
decrypted = encryptor.decrypt(encrypted)
|
|
55
|
+
assert decrypted == text
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Tweaks
|
|
59
|
+
|
|
60
|
+
A tweak is optional context data that gets mixed into the encryption.
|
|
61
|
+
The same plaintext encrypted with different tweaks produces different ciphertext,
|
|
62
|
+
which is useful for binding tokens to a specific user, session, or tenant:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
enc_alice = encryptor.encrypt(text, tweak=b"user-alice")
|
|
66
|
+
enc_bob = encryptor.encrypt(text, tweak=b"user-bob")
|
|
67
|
+
|
|
68
|
+
assert enc_alice != enc_bob
|
|
69
|
+
|
|
70
|
+
# Each can only be decrypted with the matching tweak
|
|
71
|
+
assert encryptor.decrypt(enc_alice, tweak=b"user-alice") == text
|
|
72
|
+
assert encryptor.decrypt(enc_bob, tweak=b"user-bob") == text
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Filtering by token type
|
|
76
|
+
|
|
77
|
+
If you only want to encrypt certain kinds of tokens and leave the rest as-is,
|
|
78
|
+
pass a `types` list with the pattern names you care about:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
text = "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij and AKIAIOSFODNN7EXAMPLE"
|
|
82
|
+
|
|
83
|
+
encrypted = encryptor.encrypt(text, types=["github-pat"])
|
|
84
|
+
# The GitHub token is encrypted, but the AWS key is untouched
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Supported token types
|
|
88
|
+
|
|
89
|
+
The following patterns are detected and encrypted out of the box:
|
|
90
|
+
|
|
91
|
+
| Provider | Pattern name(s) | Prefix |
|
|
92
|
+
| -------------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------- |
|
|
93
|
+
| Anthropic | `anthropic` | `sk-ant-api03-` |
|
|
94
|
+
| AWS | `aws-access-key` | `AKIA` |
|
|
95
|
+
| Datadog | `datadog` | `ddapi_` |
|
|
96
|
+
| GitHub | `github-pat`, `github-oauth`, `github-user`, `github-server`, `github-refresh` | `ghp_`, `gho_`, `ghu_`, `ghs_`, `ghr_` |
|
|
97
|
+
| GitLab | `gitlab` | `glpat-` |
|
|
98
|
+
| Google | `google-api` | `AIza` |
|
|
99
|
+
| Grafana | `grafana` | `glc_` |
|
|
100
|
+
| HuggingFace | `huggingface` | `hf_` |
|
|
101
|
+
| npm | `npm` | `npm_` |
|
|
102
|
+
| OpenAI | `openai`, `openai-legacy` | `sk-proj-`, `sk-` |
|
|
103
|
+
| PyPI | `pypi` | `pypi-` |
|
|
104
|
+
| SendGrid | `sendgrid` | `SG.` |
|
|
105
|
+
| Slack | `slack-bot`, `slack-user` | `xoxb-`, `xoxp-` |
|
|
106
|
+
| Stripe | `stripe-secret-live`, `stripe-publish-live`, `stripe-secret-test`, `stripe-publish-test` | `sk_live_`, `pk_live_`, `sk_test_`, `pk_test_` |
|
|
107
|
+
| Supabase | `supabase` | `sbp_` |
|
|
108
|
+
| Twilio | `twilio` | `SK` |
|
|
109
|
+
| Vercel | `vercel` | `vercel_` |
|
|
110
|
+
| Fastly | `fastly` | *(heuristic, no prefix)* |
|
|
111
|
+
| AWS Secret Key | `aws-secret-key` | *(heuristic, no prefix)* |
|
|
112
|
+
|
|
113
|
+
Heuristic patterns don't rely on a prefix. They look for strings with high entropy and mixed character classes, which is how secrets like Fastly tokens and AWS secret keys are typically formatted. Because there is no distinguishing prefix, encrypted output is wrapped in an `[ENCRYPTED:<name>]` marker. `decrypt()` will attempt to unwrap anything matching that marker pattern, so avoid feeding text containing literal `[ENCRYPTED:...]` strings that were not produced by `encrypt()`.
|
|
114
|
+
|
|
115
|
+
## Custom token patterns
|
|
116
|
+
|
|
117
|
+
You can register your own patterns for tokens that aren't covered by the built-in set.
|
|
118
|
+
A `SimpleTokenPattern` works for anything that has a fixed prefix followed by a body with a known alphabet:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from fast_cipher.tokens import ALPHANUMERIC, TokenEncryptor
|
|
122
|
+
from fast_cipher.tokens.types import SimpleTokenPattern
|
|
123
|
+
|
|
124
|
+
my_pattern = SimpleTokenPattern(
|
|
125
|
+
name="myapp-api-key",
|
|
126
|
+
prefix="myapp_",
|
|
127
|
+
body_regex="[A-Za-z0-9]{32}",
|
|
128
|
+
body_alphabet=ALPHANUMERIC,
|
|
129
|
+
min_body_length=32,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
key = os.urandom(32)
|
|
133
|
+
encryptor = TokenEncryptor(key)
|
|
134
|
+
encryptor.register(my_pattern)
|
|
135
|
+
|
|
136
|
+
text = "key: myapp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef"
|
|
137
|
+
encrypted = encryptor.encrypt(text)
|
|
138
|
+
decrypted = encryptor.decrypt(encrypted)
|
|
139
|
+
assert decrypted == text
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Registered patterns take priority over built-in ones.
|
|
143
|
+
|
|
144
|
+
The available alphabets are `DIGITS`, `HEX_LOWER`, `ALPHANUMERIC_UPPER`, `ALPHANUMERIC_LOWER`, `ALPHANUMERIC`, `BASE64`, and `BASE64URL`.
|
|
145
|
+
You can also create your own with `Alphabet(name="my-abc", chars="abc...")`.
|
|
146
|
+
|
|
147
|
+
## Low-level cipher
|
|
148
|
+
|
|
149
|
+
`TokenEncryptor` is built on top of `FastCipher`, which you can use directly when you need format-preserving encryption for arbitrary data. It works on sequences of integers in a given radix (base).
|
|
150
|
+
|
|
151
|
+
For example, to encrypt an 8-digit decimal number:
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
from fast_cipher import FastCipher, calculate_recommended_params
|
|
155
|
+
|
|
156
|
+
params = calculate_recommended_params(radix=10, word_length=8)
|
|
157
|
+
key = os.urandom(32)
|
|
158
|
+
cipher = FastCipher(params, key)
|
|
159
|
+
|
|
160
|
+
plaintext = [1, 2, 3, 4, 5, 6, 7, 8]
|
|
161
|
+
ciphertext = cipher.encrypt(plaintext)
|
|
162
|
+
|
|
163
|
+
# Result is still 8 digits, each between 0 and 9
|
|
164
|
+
assert len(ciphertext) == 8
|
|
165
|
+
assert all(0 <= d < 10 for d in ciphertext)
|
|
166
|
+
|
|
167
|
+
decrypted = cipher.decrypt(ciphertext)
|
|
168
|
+
assert decrypted == plaintext
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
For raw bytes, use radix 256 with the `encrypt_bytes`/`decrypt_bytes` convenience methods:
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
params = calculate_recommended_params(radix=256, word_length=16)
|
|
175
|
+
cipher = FastCipher(params, key)
|
|
176
|
+
|
|
177
|
+
ciphertext = cipher.encrypt_bytes(b"sensitive data!!")
|
|
178
|
+
plaintext = cipher.decrypt_bytes(ciphertext)
|
|
179
|
+
assert plaintext == b"sensitive data!!"
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Cleanup
|
|
183
|
+
|
|
184
|
+
When you're done with an encryptor or cipher, call `destroy()` to invalidate the instance:
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
encryptor.destroy()
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
After `destroy()`, any further calls to `encrypt()` or `decrypt()` will raise a `RuntimeError`.
|
|
191
|
+
The method overwrites key references with zero-filled placeholders and clears internal state,
|
|
192
|
+
but Python's garbage collector may retain copies of the original key bytes in memory.
|
|
193
|
+
For applications that require guaranteed memory scrubbing, use a C-level implementation instead.
|
|
194
|
+
|
|
195
|
+
## Cross-implementation compatibility
|
|
196
|
+
|
|
197
|
+
Ciphertext produced by any FAST implementation (C, Zig, Go, JavaScript, Python) can be decrypted by any other, as long as the key, radix, word length, and tweak match. This library is tested against the Go test vectors and cross-validated against the JavaScript implementation.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fast-cipher"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "FAST format-preserving encryption cipher in pure Python"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.14"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"cryptography>=46.0.5",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[project.optional-dependencies]
|
|
12
|
+
dev = ["pytest>=9.0.2"]
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = ["uv_build>=0.10.10,<0.11.0"]
|
|
16
|
+
build-backend = "uv_build"
|
|
17
|
+
|
|
18
|
+
[dependency-groups]
|
|
19
|
+
dev = [
|
|
20
|
+
"pytest>=9.0.2",
|
|
21
|
+
"ruff>=0.15.6",
|
|
22
|
+
]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from .cipher import FastCipher
|
|
2
|
+
from .params import calculate_recommended_params
|
|
3
|
+
from .types import (
|
|
4
|
+
FastError,
|
|
5
|
+
FastParams,
|
|
6
|
+
InvalidBranchDistError,
|
|
7
|
+
InvalidKeyError,
|
|
8
|
+
InvalidLengthError,
|
|
9
|
+
InvalidParametersError,
|
|
10
|
+
InvalidRadixError,
|
|
11
|
+
InvalidSBoxCountError,
|
|
12
|
+
InvalidValueError,
|
|
13
|
+
InvalidWordLengthError,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"FastCipher",
|
|
18
|
+
"FastError",
|
|
19
|
+
"FastParams",
|
|
20
|
+
"InvalidBranchDistError",
|
|
21
|
+
"InvalidKeyError",
|
|
22
|
+
"InvalidLengthError",
|
|
23
|
+
"InvalidParametersError",
|
|
24
|
+
"InvalidRadixError",
|
|
25
|
+
"InvalidSBoxCountError",
|
|
26
|
+
"InvalidValueError",
|
|
27
|
+
"InvalidWordLengthError",
|
|
28
|
+
"calculate_recommended_params",
|
|
29
|
+
]
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .core import cdec, cenc
|
|
4
|
+
from .encoding import build_setup1_input, build_setup2_input
|
|
5
|
+
from .prf import derive_key
|
|
6
|
+
from .prng import generate_sequence
|
|
7
|
+
from .sbox import generate_sbox_pool
|
|
8
|
+
from .types import (
|
|
9
|
+
FastParams,
|
|
10
|
+
InvalidBranchDistError,
|
|
11
|
+
InvalidKeyError,
|
|
12
|
+
InvalidLengthError,
|
|
13
|
+
InvalidRadixError,
|
|
14
|
+
InvalidSBoxCountError,
|
|
15
|
+
InvalidValueError,
|
|
16
|
+
InvalidWordLengthError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
DERIVED_KEY_SIZE = 32
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FastCipher:
|
|
23
|
+
"""FAST format-preserving encryption cipher."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, params: FastParams, key: bytes) -> None:
|
|
26
|
+
_validate_params(params, key)
|
|
27
|
+
self.params = params
|
|
28
|
+
self._master_key = bytes(key)
|
|
29
|
+
|
|
30
|
+
pool_key_material = derive_key(
|
|
31
|
+
key, build_setup1_input(params), DERIVED_KEY_SIZE
|
|
32
|
+
)
|
|
33
|
+
self._sboxes = generate_sbox_pool(
|
|
34
|
+
params.radix, params.sbox_count, bytes(pool_key_material)
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
self._cached_tweak: bytes | None = None
|
|
38
|
+
self._cached_seq: list[int] | None = None
|
|
39
|
+
self._destroyed = False
|
|
40
|
+
|
|
41
|
+
def _ensure_sequence(self, tweak: bytes) -> list[int]:
|
|
42
|
+
if self._cached_seq is not None and self._cached_tweak == tweak:
|
|
43
|
+
return self._cached_seq
|
|
44
|
+
|
|
45
|
+
seq_key_material = derive_key(
|
|
46
|
+
self._master_key,
|
|
47
|
+
build_setup2_input(self.params, tweak),
|
|
48
|
+
DERIVED_KEY_SIZE,
|
|
49
|
+
)
|
|
50
|
+
seq = generate_sequence(
|
|
51
|
+
self.params.num_layers,
|
|
52
|
+
self.params.sbox_count,
|
|
53
|
+
bytes(seq_key_material),
|
|
54
|
+
)
|
|
55
|
+
self._cached_tweak = tweak
|
|
56
|
+
self._cached_seq = seq
|
|
57
|
+
return seq
|
|
58
|
+
|
|
59
|
+
def _assert_alive(self) -> None:
|
|
60
|
+
if self._destroyed:
|
|
61
|
+
raise RuntimeError("FastCipher has been destroyed")
|
|
62
|
+
|
|
63
|
+
def _validate_input(self, data: bytes | list[int]) -> list[int]:
|
|
64
|
+
values = list(data)
|
|
65
|
+
if len(values) != self.params.word_length:
|
|
66
|
+
raise InvalidLengthError(
|
|
67
|
+
f"Expected {self.params.word_length} elements, got {len(values)}"
|
|
68
|
+
)
|
|
69
|
+
for v in values:
|
|
70
|
+
if not (0 <= v < self.params.radix):
|
|
71
|
+
raise InvalidValueError(
|
|
72
|
+
f"Value {v} out of range [0, {self.params.radix})"
|
|
73
|
+
)
|
|
74
|
+
return values
|
|
75
|
+
|
|
76
|
+
def encrypt(self, plaintext: bytes | list[int], tweak: bytes = b"") -> list[int]:
|
|
77
|
+
self._assert_alive()
|
|
78
|
+
values = self._validate_input(plaintext)
|
|
79
|
+
seq = self._ensure_sequence(tweak)
|
|
80
|
+
return cenc(self.params, self._sboxes, seq, values)
|
|
81
|
+
|
|
82
|
+
def decrypt(self, ciphertext: bytes | list[int], tweak: bytes = b"") -> list[int]:
|
|
83
|
+
self._assert_alive()
|
|
84
|
+
values = self._validate_input(ciphertext)
|
|
85
|
+
seq = self._ensure_sequence(tweak)
|
|
86
|
+
return cdec(self.params, self._sboxes, seq, values)
|
|
87
|
+
|
|
88
|
+
def encrypt_bytes(self, plaintext: bytes, tweak: bytes = b"") -> bytes:
|
|
89
|
+
return bytes(self.encrypt(plaintext, tweak))
|
|
90
|
+
|
|
91
|
+
def decrypt_bytes(self, ciphertext: bytes, tweak: bytes = b"") -> bytes:
|
|
92
|
+
return bytes(self.decrypt(ciphertext, tweak))
|
|
93
|
+
|
|
94
|
+
def destroy(self) -> None:
|
|
95
|
+
self._destroyed = True
|
|
96
|
+
self._master_key = b"\x00" * len(self._master_key)
|
|
97
|
+
self._sboxes = []
|
|
98
|
+
self._cached_seq = None
|
|
99
|
+
self._cached_tweak = None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _validate_params(params: FastParams, key: bytes) -> None:
|
|
103
|
+
if params.radix < 4 or params.radix > 256:
|
|
104
|
+
raise InvalidRadixError("Radix must be between 4 and 256")
|
|
105
|
+
|
|
106
|
+
if params.word_length < 1:
|
|
107
|
+
raise InvalidWordLengthError("Word length must be >= 1")
|
|
108
|
+
|
|
109
|
+
if params.num_layers < 1:
|
|
110
|
+
raise InvalidWordLengthError("num_layers must be >= 1")
|
|
111
|
+
|
|
112
|
+
if params.word_length > 1 and params.num_layers % params.word_length != 0:
|
|
113
|
+
raise InvalidWordLengthError("num_layers must be a multiple of word_length")
|
|
114
|
+
|
|
115
|
+
if params.sbox_count < 1:
|
|
116
|
+
raise InvalidSBoxCountError("S-box count must be >= 1")
|
|
117
|
+
|
|
118
|
+
if params.branch_dist1 < 0:
|
|
119
|
+
raise InvalidBranchDistError("branch_dist1 must be >= 0")
|
|
120
|
+
|
|
121
|
+
if params.branch_dist2 < 0:
|
|
122
|
+
raise InvalidBranchDistError("branch_dist2 must be >= 0")
|
|
123
|
+
|
|
124
|
+
if params.word_length > 1:
|
|
125
|
+
if params.branch_dist1 > params.word_length - 2:
|
|
126
|
+
raise InvalidBranchDistError("branch_dist1 must be <= word_length - 2")
|
|
127
|
+
if params.branch_dist2 == 0 or params.branch_dist2 > params.word_length - 1:
|
|
128
|
+
raise InvalidBranchDistError("branch_dist2 is out of valid range")
|
|
129
|
+
|
|
130
|
+
if len(key) not in (16, 24, 32):
|
|
131
|
+
raise InvalidKeyError("Key must be 16, 24, or 32 bytes")
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .layers import ds_layer, es_layer
|
|
4
|
+
from .sbox import SBox
|
|
5
|
+
from .types import FastParams
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def cenc(
|
|
9
|
+
params: FastParams,
|
|
10
|
+
sboxes: list[SBox],
|
|
11
|
+
seq: list[int],
|
|
12
|
+
plaintext: list[int],
|
|
13
|
+
) -> list[int]:
|
|
14
|
+
"""Component encryption: apply all ES layers in forward order."""
|
|
15
|
+
data = list(plaintext)
|
|
16
|
+
if params.word_length == 1:
|
|
17
|
+
for layer in range(params.num_layers):
|
|
18
|
+
data[0] = sboxes[seq[layer]].perm[data[0]]
|
|
19
|
+
return data
|
|
20
|
+
for layer in range(params.num_layers):
|
|
21
|
+
es_layer(params, sboxes[seq[layer]], data)
|
|
22
|
+
return data
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def cdec(
|
|
26
|
+
params: FastParams,
|
|
27
|
+
sboxes: list[SBox],
|
|
28
|
+
seq: list[int],
|
|
29
|
+
ciphertext: list[int],
|
|
30
|
+
) -> list[int]:
|
|
31
|
+
"""Component decryption: apply all DS layers in reverse order."""
|
|
32
|
+
data = list(ciphertext)
|
|
33
|
+
if params.word_length == 1:
|
|
34
|
+
for layer in range(params.num_layers - 1, -1, -1):
|
|
35
|
+
data[0] = sboxes[seq[layer]].inv[data[0]]
|
|
36
|
+
return data
|
|
37
|
+
for layer in range(params.num_layers - 1, -1, -1):
|
|
38
|
+
ds_layer(params, sboxes[seq[layer]], data)
|
|
39
|
+
return data
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import struct
|
|
4
|
+
|
|
5
|
+
from .types import FastParams
|
|
6
|
+
|
|
7
|
+
_LABEL_INSTANCE1 = b"instance1"
|
|
8
|
+
_LABEL_INSTANCE2 = b"instance2"
|
|
9
|
+
_LABEL_FPE_POOL = b"FPE Pool"
|
|
10
|
+
_LABEL_FPE_SEQ = b"FPE SEQ"
|
|
11
|
+
_LABEL_TWEAK = b"tweak"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _u32be(value: int) -> bytes:
|
|
15
|
+
return struct.pack(">I", value)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def encode_parts(parts: list[bytes]) -> bytes:
|
|
19
|
+
buf = bytearray(_u32be(len(parts)))
|
|
20
|
+
for part in parts:
|
|
21
|
+
buf.extend(_u32be(len(part)))
|
|
22
|
+
buf.extend(part)
|
|
23
|
+
return bytes(buf)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_setup1_input(params: FastParams) -> bytes:
|
|
27
|
+
return encode_parts(
|
|
28
|
+
[
|
|
29
|
+
_LABEL_INSTANCE1,
|
|
30
|
+
_u32be(params.radix),
|
|
31
|
+
_u32be(params.sbox_count),
|
|
32
|
+
_LABEL_FPE_POOL,
|
|
33
|
+
]
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build_setup2_input(params: FastParams, tweak: bytes) -> bytes:
|
|
38
|
+
return encode_parts(
|
|
39
|
+
[
|
|
40
|
+
_LABEL_INSTANCE1,
|
|
41
|
+
_u32be(params.radix),
|
|
42
|
+
_u32be(params.sbox_count),
|
|
43
|
+
_LABEL_INSTANCE2,
|
|
44
|
+
_u32be(params.word_length),
|
|
45
|
+
_u32be(params.num_layers),
|
|
46
|
+
_u32be(params.branch_dist1),
|
|
47
|
+
_u32be(params.branch_dist2),
|
|
48
|
+
_LABEL_FPE_SEQ,
|
|
49
|
+
_LABEL_TWEAK,
|
|
50
|
+
tweak,
|
|
51
|
+
]
|
|
52
|
+
)
|