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.
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jucrypt
3
- Version: 0.3.0
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.9
21
+ Requires-Python: >=3.10
21
22
  Description-Content-Type: text/markdown
22
23
  License-File: LICENSE
23
24
  Provides-Extra: experiment
@@ -0,0 +1,8 @@
1
+ from .default_sboxes import SBOX_POOL
2
+ from .storyc import STORYC
3
+ from .story import STORY
4
+ from .story256 import STORY256
5
+ from .story256c import STORYC256
6
+
7
+ __all__ = ["SBOX_POOL", "STORYC", "STORY", "STORY256", "STORYC256"]
8
+ __version__= "0.3.2"
@@ -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-02-25 16:37 UTC.
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.0
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)] # α^0 ..... α^15
60
- ys = [pow_alpha(i + 16) for i in range(16)] # α^16 .... α^31
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 = 32 # 256-bit derived key
90
- ROUNDS_MIN = 8 # Minimum actual rounds (key-derived base)
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, checks JSON at first, if present then ignores default, if not then default
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 = os.path.dirname(__file__)
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 jucrypt.default_sboxes import SBOX_POOL
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 from the story key material (bytes)."""
147
- ikm = hashlib.shake_256(story_bytes).digest(STORY.KEY_SIZE)
148
- enc_key = hmac.new(ikm, b"enc||story_v1_master", hashlib.sha256).digest()
149
- mac_key = hmac.new(ikm, b"mac||story_v1_master", hashlib.sha256).digest()
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 = len(all_sboxes)
157
- threshold = 65536 - (65536 % pool_size)
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 = 0
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
- b"story_v1_sbox||" + master + pos.to_bytes(4, "big")
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 _derive_round_count(master: bytes) -> int:
180
- span = STORY.ROUNDS_MAX - STORY.ROUNDS_MIN + 1
181
- threshold = 256 - (256 % span)
182
- stream = bytearray(hashlib.shake_256(b"story_v1_rounds||" + master).digest(32))
183
- pos = 0
184
-
185
- while True:
186
- if pos >= len(stream):
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 | domain-separated from round keys.
214
- Truncated to BLOCK_SIZE 16 bytes from the 32-byte HMAC-SHA256 output."""
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 = 0
222
- perm = list(range(16))
212
+ pos = 0
213
+ perm = list(range(16))
223
214
 
224
215
  for i in range(15, 0, -1):
225
- limit = i + 1
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 = stream[pos]
226
+ b = stream[pos]
236
227
  pos += 1
237
228
  if b < threshold:
238
- j = b % limit
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 — all operate on List[int] state in-place
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 _permute(state: List[int], perm: List[int]) -> None:
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-State MDS Mix over GF(2^8)."""
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 ^= _GF[row[j]][state[j]]
250
+ acc ^= t[j][state[j]]
264
251
  result[i] = acc
265
- for i in range(16):
266
- state[i] = result[i]
252
+ state[:] = result
267
253
 
268
- # Block encryption, pure bytes to speed up the process
254
+ # Block encryption
269
255
  @staticmethod
270
256
  def _encrypt_block(
271
- block: bytes,
272
- perm: List[int],
273
- sbox: List[int],
274
- round_keys: List[bytes],
275
- final_key: bytes,
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._sub_bytes(state, sbox)
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
- # Final whitening, closes the last Mix against key-recovery
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 / round_salt)."""
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[str, str, str, str]:
331
- """Encrypt plaintext under a story key."""
332
- pt_bytes = STORY._to_bytes(plaintext)
333
- story_norm = unicodedata.normalize("NFC", story)
334
- story_bytes = story_norm.encode("utf-16-le")
335
- enc_key, mac_key = STORY._derive_master_key(story_bytes)
336
- sbox = STORY._derive_sbox(enc_key)
337
- perm = STORY._derive_perm(enc_key)
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
- round_salt = os.urandom(1)
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
- round_keys = STORY._expand_round_keys(enc_key, actual_rounds)
345
- final_key = STORY._derive_whitening_key(enc_key)
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 = os.urandom(8)
322
+ nonce = os.urandom(8)
349
323
  ciphertext = bytearray()
350
- counter = 0
324
+ counter = 0
351
325
 
352
326
  for i in range(0, len(pt_bytes), STORY.BLOCK_SIZE):
353
- block = pt_bytes[i : i + STORY.BLOCK_SIZE]
327
+ block = pt_bytes[i : i + STORY.BLOCK_SIZE]
354
328
  keystream = STORY._encrypt_block(
355
- STORY._make_counter_block(nonce, counter),
356
- perm,
329
+ nonce + counter.to_bytes(8, "big"),
357
330
  sbox,
331
+ perm,
358
332
  round_keys,
359
- final_key,
333
+ final_int,
360
334
  )
361
- ciphertext.extend(b ^ k for b, k in zip(block, keystream))
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 || round_salt || ciphertext
339
+ # Authenticate: HMAC-SHA256 over nonce || ciphertext
365
340
  tag = hmac.new(
366
341
  mac_key,
367
- nonce + round_salt + bytes(ciphertext),
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 = STORY._to_bytes_param(ciphertext, "ciphertext")
388
- nc_bytes = STORY._to_bytes_param(nonce, "nonce")
389
- tag_bytes = STORY._to_bytes_param(tag, "tag")
390
- rs_bytes = STORY._to_bytes_param(round_salt, "round_salt")
391
- story_norm = unicodedata.normalize("NFC", story)
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
- round_keys = STORY._expand_round_keys(enc_key, actual_rounds)
401
- final_key = STORY._derive_whitening_key(enc_key)
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 + rs_bytes + ct_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, tag, or round_salt has been tampered with,\n"
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 = 0
386
+ counter = 0
419
387
 
420
388
  for i in range(0, len(ct_bytes), STORY.BLOCK_SIZE):
421
- block = ct_bytes[i : i + STORY.BLOCK_SIZE]
389
+ block = ct_bytes[i : i + STORY.BLOCK_SIZE]
422
390
  keystream = STORY._encrypt_block(
423
- STORY._make_counter_block(nc_bytes, counter),
424
- perm,
391
+ nc_bytes + counter.to_bytes(8, "big"),
425
392
  sbox,
393
+ perm,
426
394
  round_keys,
427
- final_key,
395
+ final_int,
428
396
  )
429
- plaintext.extend(b ^ k for b, k in zip(block, keystream))
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
- round_salt: Union[str, bytes],
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, round_salt).decode(encoding)
412
+ return STORY.decrypt(ciphertext, story, nonce, tag).decode(encoding)