jucrypt 0.3.0__tar.gz → 0.3.2__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.
- {jucrypt-0.3.0 → jucrypt-0.3.2}/PKG-INFO +3 -2
- jucrypt-0.3.2/jucrypt/__init__.py +8 -0
- {jucrypt-0.3.0 → jucrypt-0.3.2}/jucrypt/default_sboxes.py +2 -2
- {jucrypt-0.3.0 → jucrypt-0.3.2}/jucrypt/story.py +134 -166
- jucrypt-0.3.2/jucrypt/story128ext.c +415 -0
- jucrypt-0.3.2/jucrypt/story256.py +646 -0
- jucrypt-0.3.2/jucrypt/story256c.py +281 -0
- jucrypt-0.3.2/jucrypt/story256ext.c +334 -0
- jucrypt-0.3.2/jucrypt/storyc.py +440 -0
- {jucrypt-0.3.0 → jucrypt-0.3.2}/jucrypt.egg-info/PKG-INFO +3 -2
- {jucrypt-0.3.0 → jucrypt-0.3.2}/jucrypt.egg-info/SOURCES.txt +6 -3
- jucrypt-0.3.2/jucrypt.egg-info/top_level.txt +3 -0
- {jucrypt-0.3.0 → jucrypt-0.3.2}/pyproject.toml +12 -6
- jucrypt-0.3.2/setup.py +41 -0
- jucrypt-0.3.2/tests/test_story.py +477 -0
- jucrypt-0.3.0/jucrypt/__init__.py +0 -5
- jucrypt-0.3.0/jucrypt/story_core.c +0 -227
- jucrypt-0.3.0/jucrypt/storyc.py +0 -420
- jucrypt-0.3.0/jucrypt.egg-info/top_level.txt +0 -3
- jucrypt-0.3.0/setup.py +0 -11
- {jucrypt-0.3.0 → jucrypt-0.3.2}/LICENSE +0 -0
- {jucrypt-0.3.0 → jucrypt-0.3.2}/README.md +0 -0
- {jucrypt-0.3.0 → jucrypt-0.3.2}/jucrypt.egg-info/dependency_links.txt +0 -0
- {jucrypt-0.3.0 → jucrypt-0.3.2}/jucrypt.egg-info/entry_points.txt +0 -0
- {jucrypt-0.3.0 → jucrypt-0.3.2}/jucrypt.egg-info/requires.txt +0 -0
- {jucrypt-0.3.0 → jucrypt-0.3.2}/setup.cfg +0 -0
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: jucrypt
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: A Fully Parameterised, Story-Key Driven Experimental SPN Cipher
|
|
5
5
|
Author-email: "I. Nabil" <w3nabil@gmail.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
7
7
|
Project-URL: Homepage, https://github.com/w3nabil/jucrypt
|
|
8
8
|
Project-URL: Repository, https://github.com/w3nabil/jucrypt
|
|
9
9
|
Project-URL: Issues, https://github.com/w3nabil/jucrypt/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/w3nabil/jucrypt/changelog.md
|
|
10
11
|
Keywords: cryptography,symmetric encryption,educational crypto,experimental cipher,story-based key derivation
|
|
11
12
|
Classifier: Development Status :: 3 - Alpha
|
|
12
13
|
Classifier: Intended Audience :: Developers
|
|
@@ -17,7 +18,7 @@ Classifier: Programming Language :: Python :: 3.9
|
|
|
17
18
|
Classifier: Programming Language :: Python :: 3.10
|
|
18
19
|
Classifier: Programming Language :: Python :: 3.11
|
|
19
20
|
Classifier: Operating System :: OS Independent
|
|
20
|
-
Requires-Python: >=3.
|
|
21
|
+
Requires-Python: >=3.10
|
|
21
22
|
Description-Content-Type: text/markdown
|
|
22
23
|
License-File: LICENSE
|
|
23
24
|
Provides-Extra: experiment
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
customju/default_sboxes.py
|
|
3
3
|
==========================
|
|
4
4
|
Default S-box pool for the STORY cipher.
|
|
5
|
-
Generated by convert_sboxes.py on 2026-
|
|
6
|
-
Source: sboxes.json
|
|
5
|
+
Generated by convert_sboxes.py on 2026-03-15 16:37 UTC.
|
|
6
|
+
Source: lab/sboxes.json (DDT_max = 4, and LAT_max = 16)
|
|
7
7
|
S-boxes: 256 (keys 0..255)
|
|
8
8
|
|
|
9
9
|
Each entry is a permutation of 0..255 (0-indexed, validated).
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# =============================================================================
|
|
2
2
|
# STORY cipher by JuCrypt
|
|
3
3
|
# Path : jucrypt/story.py
|
|
4
|
-
# Version : v0.3.
|
|
4
|
+
# Version : v0.3.2
|
|
5
5
|
# Author : Nabil
|
|
6
6
|
# DOI : <>
|
|
7
7
|
# =============================================================================
|
|
@@ -56,8 +56,8 @@ def _derive_cauchy_matrices() -> List[List[int]]:
|
|
|
56
56
|
a = _GF[a][2]
|
|
57
57
|
return a
|
|
58
58
|
|
|
59
|
-
xs = [pow_alpha(i) for i in range(16)]
|
|
60
|
-
ys = [pow_alpha(i + 16) for i in range(16)] # α^16
|
|
59
|
+
xs = [pow_alpha(i) for i in range(16)] # α^0 ..... α^15
|
|
60
|
+
ys = [pow_alpha(i + 16) for i in range(16)] # α^16 ..... α^31
|
|
61
61
|
|
|
62
62
|
# Cauchy matrix: all xs distinct, all ys distinct, sets disjoint → invertible
|
|
63
63
|
M = [[gf_inv[xs[i] ^ ys[j]] for j in range(16)] for i in range(16)]
|
|
@@ -80,16 +80,35 @@ def _derive_cauchy_matrices() -> List[List[int]]:
|
|
|
80
80
|
aug[r][k] ^= _GF[f][aug[col][k]]
|
|
81
81
|
return M
|
|
82
82
|
|
|
83
|
+
|
|
83
84
|
_MIX_M = _derive_cauchy_matrices()
|
|
84
85
|
|
|
86
|
+
|
|
87
|
+
# Build once at import time alongside _GF and _MIX_M
|
|
88
|
+
def _build_mix_table() -> List[List[List[int]]]:
|
|
89
|
+
"""
|
|
90
|
+
Precompute MDS contributions:
|
|
91
|
+
_MIX_T[i][j][v] = GF_mul(MIX_M[i][j], v)
|
|
92
|
+
So _mix inner loop becomes pure XOR, zero GF mul at runtime.
|
|
93
|
+
"""
|
|
94
|
+
return [
|
|
95
|
+
[
|
|
96
|
+
[_GF[_MIX_M[i][j]][v] for v in range(256)]
|
|
97
|
+
for j in range(16)
|
|
98
|
+
]
|
|
99
|
+
for i in range(16)
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
_MIX_T = _build_mix_table()
|
|
104
|
+
|
|
105
|
+
|
|
85
106
|
# Main Story Class
|
|
86
107
|
class STORY:
|
|
87
108
|
# Constants
|
|
88
109
|
BLOCK_SIZE = 16 # 128-bit block
|
|
89
|
-
KEY_SIZE
|
|
90
|
-
|
|
91
|
-
ROUNDS_MAX = 12 # Maximum key-derived base
|
|
92
|
-
# Actual rounds = key-derived base [8-12] + salt offset [0-3] → [8-15]
|
|
110
|
+
KEY_SIZE = 32 # 256-bit derived key
|
|
111
|
+
ROUNDS = 5
|
|
93
112
|
|
|
94
113
|
# S-box cache — shared across all instances
|
|
95
114
|
_SBOXES_CACHE: dict = {}
|
|
@@ -104,15 +123,14 @@ class STORY:
|
|
|
104
123
|
f"S-box {idx}: not a bijection (not a permutation of 0–255)"
|
|
105
124
|
)
|
|
106
125
|
|
|
107
|
-
# S-box loading
|
|
108
|
-
# We are also planning add a feature where you will be able to generate 256 unique sboxes with lower DDT and LAT as well as json to python pool to speed process.
|
|
126
|
+
# S-box loading — checks JSON first, falls back to default pool
|
|
109
127
|
@classmethod
|
|
110
128
|
def _load_sboxes(cls) -> dict:
|
|
111
129
|
if cls._SBOXES_CACHE:
|
|
112
130
|
return cls._SBOXES_CACHE
|
|
113
131
|
|
|
114
|
-
base
|
|
115
|
-
json_path = os.path.join(base, "customju", "sboxes.json")
|
|
132
|
+
base = os.path.dirname(__file__)
|
|
133
|
+
json_path = os.path.join(base, "customju", "sboxes.json") # usually the package lib file if installed via pypi
|
|
116
134
|
|
|
117
135
|
if os.path.isfile(json_path):
|
|
118
136
|
with open(json_path, "r") as f:
|
|
@@ -124,7 +142,7 @@ class STORY:
|
|
|
124
142
|
return cls._SBOXES_CACHE
|
|
125
143
|
|
|
126
144
|
try:
|
|
127
|
-
from
|
|
145
|
+
from .default_sboxes import SBOX_POOL
|
|
128
146
|
except ImportError:
|
|
129
147
|
raise RuntimeError(
|
|
130
148
|
"STORY S-box pool not found.\n"
|
|
@@ -143,87 +161,60 @@ class STORY:
|
|
|
143
161
|
|
|
144
162
|
@staticmethod
|
|
145
163
|
def _derive_master_key(story_bytes: bytes) -> Tuple[bytes, bytes]:
|
|
146
|
-
"""Derive enc_key and mac_key
|
|
147
|
-
|
|
148
|
-
enc_key = hmac.new(
|
|
149
|
-
mac_key = hmac.new(
|
|
164
|
+
"""Derive enc_key and mac_key via HKDF-style SHA256."""
|
|
165
|
+
prk = hmac.new(b"story_v1_salt", story_bytes, hashlib.sha256).digest()
|
|
166
|
+
enc_key = hmac.new(prk, b"enc||story_v1_master\x01", hashlib.sha256).digest()
|
|
167
|
+
mac_key = hmac.new(prk, b"mac||story_v1_master\x02", hashlib.sha256).digest()
|
|
150
168
|
return enc_key, mac_key
|
|
151
169
|
|
|
152
170
|
@staticmethod
|
|
153
171
|
def _derive_sbox(master: bytes) -> List[int]:
|
|
154
172
|
"""Select one S-box from the pool deterministically from master key."""
|
|
155
173
|
all_sboxes = STORY._load_sboxes()
|
|
156
|
-
pool_size
|
|
157
|
-
threshold
|
|
174
|
+
pool_size = len(all_sboxes)
|
|
175
|
+
threshold = 65536 - (65536 % pool_size)
|
|
158
176
|
|
|
159
177
|
stream = bytearray(hashlib.shake_256(b"story_v1_sbox||" + master).digest(64))
|
|
160
|
-
pos
|
|
178
|
+
pos = 0
|
|
161
179
|
|
|
162
180
|
while True:
|
|
163
|
-
# Regenerate stream if exhausted
|
|
164
181
|
if pos + 1 >= len(stream):
|
|
165
182
|
stream = bytearray(
|
|
166
183
|
hashlib.shake_256(
|
|
167
|
-
|
|
184
|
+
b"story_v1_sbox||" + master + pos.to_bytes(4, "big")
|
|
168
185
|
).digest(64)
|
|
169
186
|
)
|
|
170
187
|
pos = 0
|
|
171
|
-
|
|
172
|
-
val = (stream[pos] << 8) | stream[pos + 1]
|
|
188
|
+
val = (stream[pos] << 8) | stream[pos + 1]
|
|
173
189
|
pos += 2
|
|
174
|
-
|
|
175
190
|
if val < threshold:
|
|
176
191
|
return all_sboxes[val % pool_size]
|
|
177
192
|
|
|
178
193
|
@staticmethod
|
|
179
|
-
def
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
stream = bytearray(
|
|
188
|
-
hashlib.shake_256(
|
|
189
|
-
b"story_v1_rounds||" + master + pos.to_bytes(4, "big")
|
|
190
|
-
).digest(32)
|
|
191
|
-
)
|
|
192
|
-
pos = 0
|
|
193
|
-
b = stream[pos]
|
|
194
|
-
pos += 1
|
|
195
|
-
if b < threshold:
|
|
196
|
-
return STORY.ROUNDS_MIN + (b % span)
|
|
197
|
-
|
|
198
|
-
@staticmethod
|
|
199
|
-
def _expand_round_keys(master: bytes, count: int) -> List[bytes]:
|
|
200
|
-
"""
|
|
201
|
-
Generate count round keys from master.
|
|
202
|
-
Each key is the first 16 bytes BLOCK_SIZE of HMAC-SHA256 output,
|
|
203
|
-
truncated to match the 128-bit block size."""
|
|
204
|
-
keys = []
|
|
205
|
-
for r in range(count):
|
|
206
|
-
label = b"story_v1_round||" + r.to_bytes(4, "big")
|
|
207
|
-
rk = hmac.new(master, label, hashlib.sha256).digest()[:STORY.BLOCK_SIZE]
|
|
208
|
-
keys.append(rk)
|
|
209
|
-
return keys
|
|
194
|
+
def _derive_round_keys(master: bytes) -> List[bytes]:
|
|
195
|
+
"""Derive ROUNDS x 16-byte round keys from master key."""
|
|
196
|
+
return [
|
|
197
|
+
hashlib.shake_256(
|
|
198
|
+
b"story_v1_roundkey||" + master + i.to_bytes(4, "big")
|
|
199
|
+
).digest(16)
|
|
200
|
+
for i in range(STORY.ROUNDS)
|
|
201
|
+
]
|
|
210
202
|
|
|
211
203
|
@staticmethod
|
|
212
204
|
def _derive_whitening_key(master: bytes) -> bytes:
|
|
213
|
-
"""Final AddRoundKey whitening key
|
|
214
|
-
|
|
215
|
-
return hmac.new(master, b"story_v1_whitening||", hashlib.sha256).digest()[:STORY.BLOCK_SIZE]
|
|
205
|
+
"""Final AddRoundKey whitening key — domain-separated from round keys."""
|
|
206
|
+
return hashlib.shake_256(b"story_v1_whitening||" + master).digest(16)
|
|
216
207
|
|
|
217
208
|
@staticmethod
|
|
218
209
|
def _derive_perm(master: bytes) -> List[int]:
|
|
219
|
-
"""Key-dependent byte permutation."""
|
|
210
|
+
"""Key-dependent byte permutation via unbiased Fisher-Yates."""
|
|
220
211
|
stream = bytearray(hashlib.shake_256(b"story_v1_perm||" + master).digest(64))
|
|
221
|
-
pos
|
|
222
|
-
perm
|
|
212
|
+
pos = 0
|
|
213
|
+
perm = list(range(16))
|
|
223
214
|
|
|
224
215
|
for i in range(15, 0, -1):
|
|
225
|
-
limit
|
|
226
|
-
threshold = 256 - (256 % limit)
|
|
216
|
+
limit = i + 1
|
|
217
|
+
threshold = 256 - (256 % limit)
|
|
227
218
|
while True:
|
|
228
219
|
if pos >= len(stream):
|
|
229
220
|
stream = bytearray(
|
|
@@ -232,71 +223,54 @@ class STORY:
|
|
|
232
223
|
).digest(64)
|
|
233
224
|
)
|
|
234
225
|
pos = 0
|
|
235
|
-
b
|
|
226
|
+
b = stream[pos]
|
|
236
227
|
pos += 1
|
|
237
228
|
if b < threshold:
|
|
238
|
-
j
|
|
229
|
+
j = b % limit
|
|
239
230
|
perm[i], perm[j] = perm[j], perm[i]
|
|
240
231
|
break
|
|
241
232
|
return perm
|
|
242
233
|
|
|
243
|
-
# SPN primitives
|
|
244
|
-
@staticmethod
|
|
245
|
-
def _sub_bytes(state: List[int], sbox: List[int]) -> None:
|
|
246
|
-
for i in range(16):
|
|
247
|
-
state[i] = sbox[state[i]]
|
|
248
|
-
|
|
234
|
+
# SPN primitives
|
|
249
235
|
@staticmethod
|
|
250
|
-
def
|
|
236
|
+
def _sub_permute(state: List[int], sbox: List[int], perm: List[int]) -> None:
|
|
237
|
+
"""Read from permuted position, then apply sbox — single pass."""
|
|
251
238
|
tmp = state[:]
|
|
252
239
|
for i in range(16):
|
|
253
|
-
state[i] = tmp[perm[i]]
|
|
240
|
+
state[i] = sbox[tmp[perm[i]]]
|
|
254
241
|
|
|
255
242
|
@staticmethod
|
|
256
243
|
def _mix(state: List[int]) -> None:
|
|
257
|
-
"""Full-
|
|
244
|
+
"""Full-state MDS mix over GF(2^8) — zero runtime GF mul via precomputed table."""
|
|
258
245
|
result = [0] * 16
|
|
259
246
|
for i in range(16):
|
|
247
|
+
t = _MIX_T[i]
|
|
260
248
|
acc = 0
|
|
261
|
-
row = _MIX_M[i]
|
|
262
249
|
for j in range(16):
|
|
263
|
-
acc ^=
|
|
250
|
+
acc ^= t[j][state[j]]
|
|
264
251
|
result[i] = acc
|
|
265
|
-
|
|
266
|
-
state[i] = result[i]
|
|
252
|
+
state[:] = result
|
|
267
253
|
|
|
268
|
-
# Block encryption
|
|
254
|
+
# Block encryption
|
|
269
255
|
@staticmethod
|
|
270
256
|
def _encrypt_block(
|
|
271
|
-
block: bytes,
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
round_keys: List[bytes],
|
|
275
|
-
|
|
257
|
+
block : bytes,
|
|
258
|
+
sbox : List[int],
|
|
259
|
+
perm : List[int],
|
|
260
|
+
round_keys : List[bytes],
|
|
261
|
+
final_int : int,
|
|
276
262
|
) -> bytes:
|
|
277
263
|
"""Encrypt one 16-byte block."""
|
|
278
264
|
state = list(block)
|
|
279
|
-
|
|
280
265
|
for rk in round_keys:
|
|
281
266
|
for i in range(16):
|
|
282
267
|
state[i] ^= rk[i]
|
|
283
|
-
STORY.
|
|
284
|
-
STORY._permute(state, perm)
|
|
268
|
+
STORY._sub_permute(state, sbox, perm)
|
|
285
269
|
STORY._mix(state)
|
|
270
|
+
# Final whitening — single int XOR, no loop
|
|
271
|
+
return (int.from_bytes(bytes(state), "big") ^ final_int).to_bytes(16, "big")
|
|
286
272
|
|
|
287
|
-
|
|
288
|
-
for i in range(16):
|
|
289
|
-
state[i] ^= final_key[i]
|
|
290
|
-
|
|
291
|
-
return bytes(state)
|
|
292
|
-
|
|
293
|
-
# Counter block
|
|
294
|
-
@staticmethod
|
|
295
|
-
def _make_counter_block(nonce: bytes, counter: int) -> bytes:
|
|
296
|
-
"""8-byte nonce || 8-byte big-endian counter → 16-byte block."""
|
|
297
|
-
return nonce + counter.to_bytes(8, "big")
|
|
298
|
-
|
|
299
|
-
# Input normalisation, preinstalled for software needs with less code.
|
|
273
|
+
# Input normalisation
|
|
300
274
|
@staticmethod
|
|
301
275
|
def _to_bytes(data) -> bytes:
|
|
302
276
|
"""Convert any supported plaintext type to bytes."""
|
|
@@ -307,13 +281,12 @@ class STORY:
|
|
|
307
281
|
if isinstance(data, str):
|
|
308
282
|
return data.encode("utf-16-le")
|
|
309
283
|
raise TypeError(
|
|
310
|
-
f"Plaintext must be str, bytes, or bytearray."
|
|
311
|
-
f"Got: {type(data).__name__}"
|
|
284
|
+
f"Plaintext must be str, bytes, or bytearray. Got: {type(data).__name__}"
|
|
312
285
|
)
|
|
313
286
|
|
|
314
287
|
@staticmethod
|
|
315
288
|
def _to_bytes_param(data, name: str) -> bytes:
|
|
316
|
-
"""Convert a hex string or bytes parameter (nonce / tag
|
|
289
|
+
"""Convert a hex string or bytes parameter (nonce / tag)."""
|
|
317
290
|
if isinstance(data, (bytes, bytearray)):
|
|
318
291
|
return bytes(data)
|
|
319
292
|
if isinstance(data, str):
|
|
@@ -327,118 +300,113 @@ class STORY:
|
|
|
327
300
|
|
|
328
301
|
# Public API
|
|
329
302
|
@staticmethod
|
|
330
|
-
def encrypt(plaintext, story: str) -> Tuple[
|
|
331
|
-
"""Encrypt plaintext under a story key.
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
base_rounds = STORY._derive_round_count(enc_key)
|
|
303
|
+
def encrypt(plaintext, story: str) -> Tuple[bytes, bytes, bytes]:
|
|
304
|
+
"""Encrypt plaintext under a story key.
|
|
305
|
+
|
|
306
|
+
Returns (ciphertext, nonce, tag) all as bytes.
|
|
307
|
+
"""
|
|
308
|
+
pt_bytes = STORY._to_bytes(plaintext)
|
|
309
|
+
story_norm = unicodedata.normalize("NFC", story)
|
|
310
|
+
story_bytes = story_norm.encode("utf-16-le")
|
|
339
311
|
|
|
340
|
-
|
|
341
|
-
salt_offset = round_salt[0] % 4
|
|
342
|
-
actual_rounds = base_rounds + salt_offset
|
|
312
|
+
enc_key, mac_key = STORY._derive_master_key(story_bytes)
|
|
343
313
|
|
|
344
|
-
|
|
345
|
-
|
|
314
|
+
# Key schedule — paid once per message
|
|
315
|
+
sbox = STORY._derive_sbox(enc_key)
|
|
316
|
+
perm = STORY._derive_perm(enc_key)
|
|
317
|
+
round_keys = STORY._derive_round_keys(enc_key)
|
|
318
|
+
final_key = STORY._derive_whitening_key(enc_key)
|
|
319
|
+
final_int = int.from_bytes(final_key, "big") # precomputed once
|
|
346
320
|
|
|
347
321
|
# CTR encryption
|
|
348
|
-
nonce
|
|
322
|
+
nonce = os.urandom(8)
|
|
349
323
|
ciphertext = bytearray()
|
|
350
|
-
counter
|
|
324
|
+
counter = 0
|
|
351
325
|
|
|
352
326
|
for i in range(0, len(pt_bytes), STORY.BLOCK_SIZE):
|
|
353
|
-
block
|
|
327
|
+
block = pt_bytes[i : i + STORY.BLOCK_SIZE]
|
|
354
328
|
keystream = STORY._encrypt_block(
|
|
355
|
-
|
|
356
|
-
perm,
|
|
329
|
+
nonce + counter.to_bytes(8, "big"),
|
|
357
330
|
sbox,
|
|
331
|
+
perm,
|
|
358
332
|
round_keys,
|
|
359
|
-
|
|
333
|
+
final_int,
|
|
360
334
|
)
|
|
361
|
-
|
|
335
|
+
# zip handles last partial block naturally — no padding needed in CTR
|
|
336
|
+
ciphertext.extend(ks ^ pb for ks, pb in zip(keystream, block))
|
|
362
337
|
counter += 1
|
|
363
338
|
|
|
364
|
-
# Authenticate: HMAC over nonce ||
|
|
339
|
+
# Authenticate: HMAC-SHA256 over nonce || ciphertext
|
|
365
340
|
tag = hmac.new(
|
|
366
341
|
mac_key,
|
|
367
|
-
nonce +
|
|
342
|
+
nonce + bytes(ciphertext),
|
|
368
343
|
hashlib.sha256,
|
|
369
344
|
).digest()
|
|
370
345
|
|
|
371
|
-
return (
|
|
372
|
-
bytes(ciphertext),
|
|
373
|
-
nonce,
|
|
374
|
-
tag,
|
|
375
|
-
round_salt,
|
|
376
|
-
)
|
|
346
|
+
return bytes(ciphertext), nonce, tag
|
|
377
347
|
|
|
378
348
|
@staticmethod
|
|
379
349
|
def decrypt(
|
|
380
|
-
ciphertext: Union[str, bytes],
|
|
381
|
-
story: str,
|
|
382
|
-
nonce: Union[str, bytes],
|
|
383
|
-
tag: Union[str, bytes],
|
|
384
|
-
round_salt: Union[str, bytes],
|
|
350
|
+
ciphertext : Union[str, bytes],
|
|
351
|
+
story : str,
|
|
352
|
+
nonce : Union[str, bytes],
|
|
353
|
+
tag : Union[str, bytes],
|
|
385
354
|
) -> bytes:
|
|
386
355
|
"""Decrypt and authenticate a STORY ciphertext."""
|
|
387
|
-
ct_bytes
|
|
388
|
-
nc_bytes
|
|
389
|
-
tag_bytes = STORY._to_bytes_param(tag,
|
|
390
|
-
|
|
391
|
-
story_norm
|
|
356
|
+
ct_bytes = STORY._to_bytes_param(ciphertext, "ciphertext")
|
|
357
|
+
nc_bytes = STORY._to_bytes_param(nonce, "nonce")
|
|
358
|
+
tag_bytes = STORY._to_bytes_param(tag, "tag")
|
|
359
|
+
|
|
360
|
+
story_norm = unicodedata.normalize("NFC", story)
|
|
392
361
|
story_bytes = story_norm.encode("utf-16-le")
|
|
362
|
+
|
|
393
363
|
enc_key, mac_key = STORY._derive_master_key(story_bytes)
|
|
394
|
-
sbox = STORY._derive_sbox(enc_key)
|
|
395
|
-
perm = STORY._derive_perm(enc_key)
|
|
396
|
-
base_rounds = STORY._derive_round_count(enc_key)
|
|
397
|
-
salt_offset = rs_bytes[0] % 4
|
|
398
|
-
actual_rounds = base_rounds + salt_offset
|
|
399
364
|
|
|
400
|
-
|
|
401
|
-
|
|
365
|
+
# Key schedule — paid once per message
|
|
366
|
+
sbox = STORY._derive_sbox(enc_key)
|
|
367
|
+
perm = STORY._derive_perm(enc_key)
|
|
368
|
+
round_keys = STORY._derive_round_keys(enc_key)
|
|
369
|
+
final_key = STORY._derive_whitening_key(enc_key)
|
|
370
|
+
final_int = int.from_bytes(final_key, "big") # precomputed once
|
|
402
371
|
|
|
403
|
-
# Authenticate
|
|
372
|
+
# Authenticate first — fail fast before any decryption
|
|
404
373
|
check = hmac.new(
|
|
405
374
|
mac_key,
|
|
406
|
-
nc_bytes +
|
|
375
|
+
nc_bytes + ct_bytes,
|
|
407
376
|
hashlib.sha256,
|
|
408
377
|
).digest()
|
|
409
378
|
if not hmac.compare_digest(check, tag_bytes):
|
|
410
379
|
raise ValueError(
|
|
411
380
|
"Authentication Failed.\n"
|
|
412
|
-
"The ciphertext, nonce,
|
|
381
|
+
"The ciphertext, nonce, or tag has been tampered with,\n"
|
|
413
382
|
"or the story key is incorrect."
|
|
414
383
|
)
|
|
415
|
-
|
|
416
384
|
# CTR decryption
|
|
417
385
|
plaintext = bytearray()
|
|
418
|
-
counter
|
|
386
|
+
counter = 0
|
|
419
387
|
|
|
420
388
|
for i in range(0, len(ct_bytes), STORY.BLOCK_SIZE):
|
|
421
|
-
block
|
|
389
|
+
block = ct_bytes[i : i + STORY.BLOCK_SIZE]
|
|
422
390
|
keystream = STORY._encrypt_block(
|
|
423
|
-
|
|
424
|
-
perm,
|
|
391
|
+
nc_bytes + counter.to_bytes(8, "big"),
|
|
425
392
|
sbox,
|
|
393
|
+
perm,
|
|
426
394
|
round_keys,
|
|
427
|
-
|
|
395
|
+
final_int,
|
|
428
396
|
)
|
|
429
|
-
|
|
397
|
+
# zip handles last partial block naturally — no padding needed in CTR
|
|
398
|
+
plaintext.extend(ks ^ cb for ks, cb in zip(keystream, block))
|
|
430
399
|
counter += 1
|
|
431
400
|
|
|
432
401
|
return bytes(plaintext)
|
|
433
402
|
|
|
434
403
|
@staticmethod
|
|
435
404
|
def decrypt_str(
|
|
436
|
-
ciphertext: Union[str, bytes],
|
|
437
|
-
story: str,
|
|
438
|
-
nonce: Union[str, bytes],
|
|
439
|
-
tag: Union[str, bytes],
|
|
440
|
-
|
|
441
|
-
encoding: str = "utf-16-le",
|
|
405
|
+
ciphertext : Union[str, bytes],
|
|
406
|
+
story : str,
|
|
407
|
+
nonce : Union[str, bytes],
|
|
408
|
+
tag : Union[str, bytes],
|
|
409
|
+
encoding : str = "utf-16-le",
|
|
442
410
|
) -> str:
|
|
443
411
|
"""Decrypt and decode to string. Default encoding is UTF-16-LE."""
|
|
444
|
-
return STORY.decrypt(ciphertext, story, nonce, tag
|
|
412
|
+
return STORY.decrypt(ciphertext, story, nonce, tag).decode(encoding)
|