cyphera 0.0.1a3__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.
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: cyphera
3
+ Version: 0.0.1a3
4
+ Summary: Data protection SDK — format-preserving encryption (FF1/FF3), AES-GCM, data masking, and hashing.
5
+ Author-email: Leslie Gutschow <leslie.gutschow@horizondigital.dev>
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://cyphera.io
8
+ Project-URL: Repository, https://github.com/cyphera-labs/cyphera-python
9
+ Project-URL: Issues, https://github.com/cyphera-labs/cyphera-python/issues
10
+ Keywords: encryption,fpe,format-preserving,data-protection,masking
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Security :: Cryptography
14
+ Requires-Python: >=3.9
15
+ Requires-Dist: cryptography>=41.0
@@ -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
@@ -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"
@@ -0,0 +1,142 @@
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
+ for j in range(1, blocks):
54
+ x = j.to_bytes(16, "big")
55
+ # XOR with R (not previous block) per NIST SP 800-38G
56
+ x = bytes(a ^ b for a, b in zip(x, r))
57
+ enc = self._aes_ecb(x)
58
+ out.extend(enc)
59
+ return bytes(out[:d])
60
+
61
+ def _num(self, digits: list[int]) -> int:
62
+ result = 0
63
+ for d in digits:
64
+ result = result * self._radix + d
65
+ return result
66
+
67
+ def _str(self, num: int, length: int) -> list[int]:
68
+ result = [0] * length
69
+ for i in range(length - 1, -1, -1):
70
+ result[i] = num % self._radix
71
+ num //= self._radix
72
+ return result
73
+
74
+ def _compute_b(self, v: int) -> int:
75
+ return math.ceil(math.ceil(v * math.log2(self._radix)) / 8)
76
+
77
+ def _build_p(self, u: int, n: int, t: int) -> bytes:
78
+ return bytes(
79
+ [1, 2, 1, (self._radix >> 16) & 0xFF, (self._radix >> 8) & 0xFF, self._radix & 0xFF, 10, u]
80
+ + list(n.to_bytes(4, "big"))
81
+ + list(t.to_bytes(4, "big"))
82
+ )
83
+
84
+ def _build_q(self, T: bytes, i: int, num_bytes: bytes, b: int) -> bytes:
85
+ pad = (16 - ((len(T) + 1 + b) % 16)) % 16
86
+ q = bytearray(T)
87
+ q.extend(b"\x00" * pad)
88
+ q.append(i)
89
+ if len(num_bytes) < b:
90
+ q.extend(b"\x00" * (b - len(num_bytes)))
91
+ start = max(0, len(num_bytes) - b)
92
+ q.extend(num_bytes[start:])
93
+ return bytes(q)
94
+
95
+ def _ff1_encrypt(self, pt: list[int], T: bytes) -> list[int]:
96
+ n = len(pt)
97
+ u, v = n // 2, n - n // 2
98
+ A, B = pt[:u], pt[u:]
99
+
100
+ b = self._compute_b(v)
101
+ d = 4 * ((b + 3) // 4) + 4
102
+ P = self._build_p(u, n, len(T))
103
+
104
+ for i in range(10):
105
+ num_b = self._num(B).to_bytes(max(b, 1), "big")
106
+ if len(num_b) > b:
107
+ num_b = num_b[-b:] if b > 0 else b""
108
+ Q = self._build_q(T, i, num_b, b)
109
+ R = self._prf(P + Q)
110
+ S = self._expand_s(R, d)
111
+ y = int.from_bytes(S, "big")
112
+
113
+ m = u if i % 2 == 0 else v
114
+ c = (self._num(A) + y) % (self._radix ** m)
115
+ A, B = B, self._str(c, m)
116
+
117
+ return A + B
118
+
119
+ def _ff1_decrypt(self, ct: list[int], T: bytes) -> list[int]:
120
+ n = len(ct)
121
+ u, v = n // 2, n - n // 2
122
+ A, B = ct[:u], ct[u:]
123
+
124
+ b = self._compute_b(v)
125
+ d = 4 * ((b + 3) // 4) + 4
126
+ P = self._build_p(u, n, len(T))
127
+
128
+ for i in range(9, -1, -1):
129
+ num_a = self._num(A).to_bytes(max(b, 1), "big")
130
+ if len(num_a) > b:
131
+ num_a = num_a[-b:] if b > 0 else b""
132
+ Q = self._build_q(T, i, num_a, b)
133
+ R = self._prf(P + Q)
134
+ S = self._expand_s(R, d)
135
+ y = int.from_bytes(S, "big")
136
+
137
+ m = u if i % 2 == 0 else v
138
+ mod = self._radix ** m
139
+ c = (self._num(B) - y) % mod
140
+ B, A = A, self._str(c, m)
141
+
142
+ return A + B
@@ -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
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: cyphera
3
+ Version: 0.0.1a3
4
+ Summary: Data protection SDK — format-preserving encryption (FF1/FF3), AES-GCM, data masking, and hashing.
5
+ Author-email: Leslie Gutschow <leslie.gutschow@horizondigital.dev>
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://cyphera.io
8
+ Project-URL: Repository, https://github.com/cyphera-labs/cyphera-python
9
+ Project-URL: Issues, https://github.com/cyphera-labs/cyphera-python/issues
10
+ Keywords: encryption,fpe,format-preserving,data-protection,masking
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Security :: Cryptography
14
+ Requires-Python: >=3.9
15
+ Requires-Dist: cryptography>=41.0
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ cyphera/__init__.py
4
+ cyphera/ff1.py
5
+ cyphera/ff3.py
6
+ cyphera.egg-info/PKG-INFO
7
+ cyphera.egg-info/SOURCES.txt
8
+ cyphera.egg-info/dependency_links.txt
9
+ cyphera.egg-info/requires.txt
10
+ cyphera.egg-info/top_level.txt
11
+ tests/test_ff1_nist.py
12
+ tests/test_ff3_nist.py
@@ -0,0 +1 @@
1
+ cryptography>=41.0
@@ -0,0 +1 @@
1
+ cyphera
@@ -0,0 +1,28 @@
1
+ [project]
2
+ name = "cyphera"
3
+ version = "0.0.1a3"
4
+ description = "Data protection SDK — format-preserving encryption (FF1/FF3), AES-GCM, data masking, and hashing."
5
+ license = "Apache-2.0"
6
+ requires-python = ">=3.9"
7
+ dependencies = ["cryptography>=41.0"]
8
+ authors = [
9
+ { name = "Leslie Gutschow", email = "leslie.gutschow@horizondigital.dev" },
10
+ ]
11
+ keywords = ["encryption", "fpe", "format-preserving", "data-protection", "masking"]
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Programming Language :: Python :: 3",
15
+ "Topic :: Security :: Cryptography",
16
+ ]
17
+
18
+ [project.urls]
19
+ Homepage = "https://cyphera.io"
20
+ Repository = "https://github.com/cyphera-labs/cyphera-python"
21
+ Issues = "https://github.com/cyphera-labs/cyphera-python/issues"
22
+
23
+ [build-system]
24
+ requires = ["setuptools>=68"]
25
+ build-backend = "setuptools.build_meta"
26
+
27
+ [tool.pytest.ini_options]
28
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,0 +1,25 @@
1
+ """FF3 NIST SP 800-38G test vectors (all 15)."""
2
+ from cyphera.ff3 import FF3, DIGITS
3
+
4
+ R26 = "0123456789abcdefghijklmnop"
5
+
6
+ def _t(key_hex, tweak_hex, alphabet, pt, ct):
7
+ c = FF3(bytes.fromhex(key_hex), bytes.fromhex(tweak_hex), alphabet)
8
+ assert c.encrypt(pt) == ct, f"encrypt({pt}) != {ct}"
9
+ assert c.decrypt(ct) == pt, f"decrypt({ct}) != {pt}"
10
+
11
+ def test_s01(): _t("EF4359D8D580AA4F7F036D6F04FC6A94", "D8E7920AFA330A73", DIGITS, "890121234567890000", "750918814058654607")
12
+ def test_s02(): _t("EF4359D8D580AA4F7F036D6F04FC6A94", "9A768A92F60E12D8", DIGITS, "890121234567890000", "018989839189395384")
13
+ def test_s03(): _t("EF4359D8D580AA4F7F036D6F04FC6A94", "D8E7920AFA330A73", DIGITS, "89012123456789000000789000000", "48598367162252569629397416226")
14
+ def test_s04(): _t("EF4359D8D580AA4F7F036D6F04FC6A94", "0000000000000000", DIGITS, "89012123456789000000789000000", "34695224821734535122613701434")
15
+ def test_s05(): _t("EF4359D8D580AA4F7F036D6F04FC6A94", "9A768A92F60E12D8", R26, "0123456789abcdefghi", "g2pk40i992fn20cjakb")
16
+ def test_s06(): _t("EF4359D8D580AA4F7F036D6F04FC6A942B7E151628AED2A6", "D8E7920AFA330A73", DIGITS, "890121234567890000", "646965393875028755")
17
+ def test_s07(): _t("EF4359D8D580AA4F7F036D6F04FC6A942B7E151628AED2A6", "9A768A92F60E12D8", DIGITS, "890121234567890000", "961610514491424446")
18
+ def test_s08(): _t("EF4359D8D580AA4F7F036D6F04FC6A942B7E151628AED2A6", "D8E7920AFA330A73", DIGITS, "89012123456789000000789000000", "53048884065350204541786380807")
19
+ def test_s09(): _t("EF4359D8D580AA4F7F036D6F04FC6A942B7E151628AED2A6", "0000000000000000", DIGITS, "89012123456789000000789000000", "98083802678820389295041483512")
20
+ def test_s10(): _t("EF4359D8D580AA4F7F036D6F04FC6A942B7E151628AED2A6", "9A768A92F60E12D8", R26, "0123456789abcdefghi", "i0ihe2jfj7a9opf9p88")
21
+ def test_s11(): _t("EF4359D8D580AA4F7F036D6F04FC6A942B7E151628AED2A6ABF7158809CF4F3C", "D8E7920AFA330A73", DIGITS, "890121234567890000", "922011205562777495")
22
+ def test_s12(): _t("EF4359D8D580AA4F7F036D6F04FC6A942B7E151628AED2A6ABF7158809CF4F3C", "9A768A92F60E12D8", DIGITS, "890121234567890000", "504149865578056140")
23
+ def test_s13(): _t("EF4359D8D580AA4F7F036D6F04FC6A942B7E151628AED2A6ABF7158809CF4F3C", "D8E7920AFA330A73", DIGITS, "89012123456789000000789000000", "04344343235792599165734622699")
24
+ def test_s14(): _t("EF4359D8D580AA4F7F036D6F04FC6A942B7E151628AED2A6ABF7158809CF4F3C", "0000000000000000", DIGITS, "89012123456789000000789000000", "30859239999374053872365555822")
25
+ def test_s15(): _t("EF4359D8D580AA4F7F036D6F04FC6A942B7E151628AED2A6ABF7158809CF4F3C", "9A768A92F60E12D8", R26, "0123456789abcdefghi", "p0b2godfja9bhb7bk38")