Skip to content

Commit 7c1d57f

Browse files
committed
Initial: FF1 + FF3 with all 24 NIST vectors passing, pure Python
0 parents  commit 7c1d57f

9 files changed

Lines changed: 416 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
name: CI
2+
on:
3+
push: { branches: [main] }
4+
pull_request: { branches: [main] }
5+
jobs:
6+
test:
7+
runs-on: ubuntu-latest
8+
steps:
9+
- uses: actions/checkout@v4
10+
- uses: actions/setup-python@v5
11+
with: { python-version: "3.12" }
12+
- run: pip install -e ".[dev]" cryptography pytest
13+
- run: pytest -v

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.venv/
2+
__pycache__/
3+
*.pyc
4+
*.egg-info/
5+
dist/
6+
build/
7+
.pytest_cache/

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# cyphera
2+
3+
Data obfuscation SDK for Python. FPE, AES, masking, hashing.
4+
5+
```
6+
pip install cyphera
7+
```
8+
9+
```python
10+
from cyphera import FF1
11+
12+
cipher = FF1(key, tweak)
13+
encrypted = cipher.encrypt("0123456789")
14+
decrypted = cipher.decrypt(encrypted)
15+
```
16+
17+
## Status
18+
19+
Early development. FF1 and FF3 engines with all NIST test vectors.
20+
21+
## License
22+
23+
Apache 2.0

cyphera/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from cyphera.ff1 import FF1
2+
from cyphera.ff3 import FF3
3+
4+
__all__ = ["FF1", "FF3"]
5+
__version__ = "0.1.0"

cyphera/ff1.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
"""FF1 Format-Preserving Encryption (NIST SP 800-38G)."""
2+
3+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
4+
import math
5+
6+
DIGITS = "0123456789"
7+
ALPHANUMERIC = "0123456789abcdefghijklmnopqrstuvwxyz"
8+
9+
10+
class FF1:
11+
def __init__(self, key: bytes, tweak: bytes, alphabet: str = ALPHANUMERIC):
12+
if len(key) not in (16, 24, 32):
13+
raise ValueError(f"Key must be 16, 24, or 32 bytes, got {len(key)}")
14+
if len(alphabet) < 2:
15+
raise ValueError("Alphabet must have >= 2 characters")
16+
self._key = key
17+
self._tweak = tweak
18+
self._alphabet = alphabet
19+
self._radix = len(alphabet)
20+
self._char_to_int = {c: i for i, c in enumerate(alphabet)}
21+
22+
def encrypt(self, plaintext: str) -> str:
23+
digits = self._to_digits(plaintext)
24+
result = self._ff1_encrypt(digits, self._tweak)
25+
return self._from_digits(result)
26+
27+
def decrypt(self, ciphertext: str) -> str:
28+
digits = self._to_digits(ciphertext)
29+
result = self._ff1_decrypt(digits, self._tweak)
30+
return self._from_digits(result)
31+
32+
def _to_digits(self, s: str) -> list[int]:
33+
return [self._char_to_int[c] for c in s]
34+
35+
def _from_digits(self, d: list[int]) -> str:
36+
return "".join(self._alphabet[i] for i in d)
37+
38+
def _aes_ecb(self, block: bytes) -> bytes:
39+
cipher = Cipher(algorithms.AES(self._key), modes.ECB())
40+
enc = cipher.encryptor()
41+
return enc.update(block) + enc.finalize()
42+
43+
def _prf(self, data: bytes) -> bytes:
44+
y = b"\x00" * 16
45+
for i in range(0, len(data), 16):
46+
block = bytes(a ^ b for a, b in zip(y, data[i : i + 16]))
47+
y = self._aes_ecb(block)
48+
return y
49+
50+
def _expand_s(self, r: bytes, d: int) -> bytes:
51+
blocks = (d + 15) // 16
52+
out = bytearray(r)
53+
prev = r
54+
for j in range(1, blocks):
55+
x = j.to_bytes(16, "big")
56+
x = bytes(a ^ b for a, b in zip(x, prev))
57+
enc = self._aes_ecb(x)
58+
out.extend(enc)
59+
prev = enc
60+
return bytes(out[:d])
61+
62+
def _num(self, digits: list[int]) -> int:
63+
result = 0
64+
for d in digits:
65+
result = result * self._radix + d
66+
return result
67+
68+
def _str(self, num: int, length: int) -> list[int]:
69+
result = [0] * length
70+
for i in range(length - 1, -1, -1):
71+
result[i] = num % self._radix
72+
num //= self._radix
73+
return result
74+
75+
def _compute_b(self, v: int) -> int:
76+
return math.ceil(math.ceil(v * math.log2(self._radix)) / 8)
77+
78+
def _build_p(self, u: int, n: int, t: int) -> bytes:
79+
return bytes(
80+
[1, 2, 1, (self._radix >> 16) & 0xFF, (self._radix >> 8) & 0xFF, self._radix & 0xFF, 10, u]
81+
+ list(n.to_bytes(4, "big"))
82+
+ list(t.to_bytes(4, "big"))
83+
)
84+
85+
def _build_q(self, T: bytes, i: int, num_bytes: bytes, b: int) -> bytes:
86+
pad = (16 - ((len(T) + 1 + b) % 16)) % 16
87+
q = bytearray(T)
88+
q.extend(b"\x00" * pad)
89+
q.append(i)
90+
if len(num_bytes) < b:
91+
q.extend(b"\x00" * (b - len(num_bytes)))
92+
start = max(0, len(num_bytes) - b)
93+
q.extend(num_bytes[start:])
94+
return bytes(q)
95+
96+
def _ff1_encrypt(self, pt: list[int], T: bytes) -> list[int]:
97+
n = len(pt)
98+
u, v = n // 2, n - n // 2
99+
A, B = pt[:u], pt[u:]
100+
101+
b = self._compute_b(v)
102+
d = 4 * ((b + 3) // 4) + 4
103+
P = self._build_p(u, n, len(T))
104+
105+
for i in range(10):
106+
num_b = self._num(B).to_bytes(max(b, 1), "big")
107+
if len(num_b) > b:
108+
num_b = num_b[-b:] if b > 0 else b""
109+
Q = self._build_q(T, i, num_b, b)
110+
R = self._prf(P + Q)
111+
S = self._expand_s(R, d)
112+
y = int.from_bytes(S, "big")
113+
114+
m = u if i % 2 == 0 else v
115+
c = (self._num(A) + y) % (self._radix ** m)
116+
A, B = B, self._str(c, m)
117+
118+
return A + B
119+
120+
def _ff1_decrypt(self, ct: list[int], T: bytes) -> list[int]:
121+
n = len(ct)
122+
u, v = n // 2, n - n // 2
123+
A, B = ct[:u], ct[u:]
124+
125+
b = self._compute_b(v)
126+
d = 4 * ((b + 3) // 4) + 4
127+
P = self._build_p(u, n, len(T))
128+
129+
for i in range(9, -1, -1):
130+
num_a = self._num(A).to_bytes(max(b, 1), "big")
131+
if len(num_a) > b:
132+
num_a = num_a[-b:] if b > 0 else b""
133+
Q = self._build_q(T, i, num_a, b)
134+
R = self._prf(P + Q)
135+
S = self._expand_s(R, d)
136+
y = int.from_bytes(S, "big")
137+
138+
m = u if i % 2 == 0 else v
139+
mod = self._radix ** m
140+
c = (self._num(B) - y) % mod
141+
B, A = A, self._str(c, m)
142+
143+
return A + B

cyphera/ff3.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""FF3-1 Format-Preserving Encryption (NIST SP 800-38G Rev 1)."""
2+
3+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
4+
5+
DIGITS = "0123456789"
6+
ALPHANUMERIC = "0123456789abcdefghijklmnopqrstuvwxyz"
7+
8+
9+
class FF3:
10+
def __init__(self, key: bytes, tweak: bytes, alphabet: str = ALPHANUMERIC):
11+
if len(key) not in (16, 24, 32):
12+
raise ValueError(f"Key must be 16, 24, or 32 bytes, got {len(key)}")
13+
if len(tweak) != 8:
14+
raise ValueError(f"Tweak must be exactly 8 bytes, got {len(tweak)}")
15+
if len(alphabet) < 2:
16+
raise ValueError("Alphabet must have >= 2 characters")
17+
# FF3 reverses the key
18+
self._key = key[::-1]
19+
self._tweak = tweak
20+
self._alphabet = alphabet
21+
self._radix = len(alphabet)
22+
self._char_to_int = {c: i for i, c in enumerate(alphabet)}
23+
24+
def encrypt(self, plaintext: str) -> str:
25+
digits = self._to_digits(plaintext)
26+
result = self._ff3_encrypt(digits)
27+
return self._from_digits(result)
28+
29+
def decrypt(self, ciphertext: str) -> str:
30+
digits = self._to_digits(ciphertext)
31+
result = self._ff3_decrypt(digits)
32+
return self._from_digits(result)
33+
34+
def _to_digits(self, s: str) -> list[int]:
35+
return [self._char_to_int[c] for c in s]
36+
37+
def _from_digits(self, d: list[int]) -> str:
38+
return "".join(self._alphabet[i] for i in d)
39+
40+
def _aes_ecb(self, block: bytes) -> bytes:
41+
cipher = Cipher(algorithms.AES(self._key), modes.ECB())
42+
enc = cipher.encryptor()
43+
return enc.update(block) + enc.finalize()
44+
45+
def _num(self, digits: list[int]) -> int:
46+
result = 0
47+
for d in digits:
48+
result = result * self._radix + d
49+
return result
50+
51+
def _str(self, num: int, length: int) -> list[int]:
52+
result = [0] * length
53+
for i in range(length - 1, -1, -1):
54+
result[i] = num % self._radix
55+
num //= self._radix
56+
return result
57+
58+
def _calc_p(self, round_num: int, w: bytes, half: list[int]) -> int:
59+
inp = bytearray(16)
60+
inp[0:4] = w
61+
inp[3] ^= round_num
62+
63+
rev_half = list(reversed(half))
64+
half_num = self._num(rev_half)
65+
half_bytes = half_num.to_bytes(max(1, (half_num.bit_length() + 7) // 8), "big") if half_num > 0 else b"\x00"
66+
67+
if len(half_bytes) <= 12:
68+
inp[16 - len(half_bytes) : 16] = half_bytes
69+
else:
70+
inp[4:16] = half_bytes[-12:]
71+
72+
rev_inp = bytes(reversed(inp))
73+
aes_out = self._aes_ecb(rev_inp)
74+
rev_out = bytes(reversed(aes_out))
75+
return int.from_bytes(rev_out, "big")
76+
77+
def _ff3_encrypt(self, pt: list[int]) -> list[int]:
78+
n = len(pt)
79+
u = (n + 1) // 2
80+
v = n - u
81+
A, B = pt[:u], pt[u:]
82+
83+
for i in range(8):
84+
if i % 2 == 0:
85+
w = self._tweak[4:8]
86+
p = self._calc_p(i, w, B)
87+
m = self._radix ** u
88+
a_num = self._num(list(reversed(A)))
89+
y = (a_num + p) % m
90+
new = self._str(y, u)
91+
A = list(reversed(new))
92+
else:
93+
w = self._tweak[0:4]
94+
p = self._calc_p(i, w, A)
95+
m = self._radix ** v
96+
b_num = self._num(list(reversed(B)))
97+
y = (b_num + p) % m
98+
new = self._str(y, v)
99+
B = list(reversed(new))
100+
101+
return A + B
102+
103+
def _ff3_decrypt(self, ct: list[int]) -> list[int]:
104+
n = len(ct)
105+
u = (n + 1) // 2
106+
v = n - u
107+
A, B = ct[:u], ct[u:]
108+
109+
for i in range(7, -1, -1):
110+
if i % 2 == 0:
111+
w = self._tweak[4:8]
112+
p = self._calc_p(i, w, B)
113+
m = self._radix ** u
114+
a_num = self._num(list(reversed(A)))
115+
y = (a_num - p) % m
116+
new = self._str(y, u)
117+
A = list(reversed(new))
118+
else:
119+
w = self._tweak[0:4]
120+
p = self._calc_p(i, w, A)
121+
m = self._radix ** v
122+
b_num = self._num(list(reversed(B)))
123+
y = (b_num - p) % m
124+
new = self._str(y, v)
125+
B = list(reversed(new))
126+
127+
return A + B

pyproject.toml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
[project]
2+
name = "cyphera"
3+
version = "0.1.0"
4+
description = "Data obfuscation SDK — FPE, AES, masking, hashing"
5+
license = "Apache-2.0"
6+
requires-python = ">=3.9"
7+
dependencies = ["cryptography>=41.0"]
8+
9+
[project.urls]
10+
Repository = "https://github.com/cyphera-labs/cyphera-python"
11+
12+
[build-system]
13+
requires = ["setuptools>=68"]
14+
build-backend = "setuptools.build_meta"
15+
16+
[tool.pytest.ini_options]
17+
testpaths = ["tests"]

tests/test_ff1_nist.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""FF1 NIST SP 800-38G test vectors."""
2+
from cyphera.ff1 import FF1, DIGITS, ALPHANUMERIC
3+
4+
5+
def test_sample_1():
6+
c = FF1(bytes.fromhex("2B7E151628AED2A6ABF7158809CF4F3C"), b"", DIGITS)
7+
assert c.encrypt("0123456789") == "2433477484"
8+
assert c.decrypt("2433477484") == "0123456789"
9+
10+
11+
def test_sample_2():
12+
c = FF1(bytes.fromhex("2B7E151628AED2A6ABF7158809CF4F3C"), bytes.fromhex("39383736353433323130"), DIGITS)
13+
assert c.encrypt("0123456789") == "6124200773"
14+
assert c.decrypt("6124200773") == "0123456789"
15+
16+
17+
def test_sample_3():
18+
c = FF1(bytes.fromhex("2B7E151628AED2A6ABF7158809CF4F3C"), bytes.fromhex("3737373770717273373737"), ALPHANUMERIC)
19+
assert c.encrypt("0123456789abcdefghi") == "a9tv40mll9kdu509eum"
20+
assert c.decrypt("a9tv40mll9kdu509eum") == "0123456789abcdefghi"
21+
22+
23+
def test_sample_4():
24+
c = FF1(bytes.fromhex("2B7E151628AED2A6ABF7158809CF4F3CEF4359D8D580AA4F"), b"", DIGITS)
25+
assert c.encrypt("0123456789") == "2830668132"
26+
assert c.decrypt("2830668132") == "0123456789"
27+
28+
29+
def test_sample_5():
30+
c = FF1(bytes.fromhex("2B7E151628AED2A6ABF7158809CF4F3CEF4359D8D580AA4F"), bytes.fromhex("39383736353433323130"), DIGITS)
31+
assert c.encrypt("0123456789") == "2496655549"
32+
assert c.decrypt("2496655549") == "0123456789"
33+
34+
35+
def test_sample_6():
36+
c = FF1(bytes.fromhex("2B7E151628AED2A6ABF7158809CF4F3CEF4359D8D580AA4F"), bytes.fromhex("3737373770717273373737"), ALPHANUMERIC)
37+
assert c.encrypt("0123456789abcdefghi") == "xbj3kv35jrawxv32ysr"
38+
assert c.decrypt("xbj3kv35jrawxv32ysr") == "0123456789abcdefghi"
39+
40+
41+
def test_sample_7():
42+
c = FF1(bytes.fromhex("2B7E151628AED2A6ABF7158809CF4F3CEF4359D8D580AA4F7F036D6F04FC6A94"), b"", DIGITS)
43+
assert c.encrypt("0123456789") == "6657667009"
44+
assert c.decrypt("6657667009") == "0123456789"
45+
46+
47+
def test_sample_8():
48+
c = FF1(bytes.fromhex("2B7E151628AED2A6ABF7158809CF4F3CEF4359D8D580AA4F7F036D6F04FC6A94"), bytes.fromhex("39383736353433323130"), DIGITS)
49+
assert c.encrypt("0123456789") == "1001623463"
50+
assert c.decrypt("1001623463") == "0123456789"
51+
52+
53+
def test_sample_9():
54+
c = FF1(bytes.fromhex("2B7E151628AED2A6ABF7158809CF4F3CEF4359D8D580AA4F7F036D6F04FC6A94"), bytes.fromhex("3737373770717273373737"), ALPHANUMERIC)
55+
assert c.encrypt("0123456789abcdefghi") == "xs8a0azh2avyalyzuwd"
56+
assert c.decrypt("xs8a0azh2avyalyzuwd") == "0123456789abcdefghi"

0 commit comments

Comments
 (0)