iqcc-shared 0.1.0__py3-none-any.whl

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,67 @@
1
+ """iqcc-shared — Symmetric Encryption with Post-Quantum Key Exchange.
2
+
3
+ Provides hybrid encryption (ML-KEM-768 + AES-256-GCM),
4
+ automated key rotation, and strict msgpack serialization.
5
+
6
+ Key features:
7
+ • Post-quantum key encapsulation (ML-KEM-768)
8
+ • AES-256-GCM symmetric payload encryption with per-field auth tags
9
+ • Dot-notation path resolver with wildcard (``.*``) support
10
+ • Strict msgpack serialization with security hardening
11
+ • Automated key rotation with overlap window
12
+ """
13
+
14
+ __version__ = "0.1.0"
15
+
16
+ # ── Public API ──────────────────────────────────────────────────────────
17
+
18
+ from .crypto import (
19
+ KEY_ROTATION_WINDOW_DAYS,
20
+ create_or_rotate_key_record,
21
+ decrypt,
22
+ encrypt,
23
+ generate_mlkem_keypair,
24
+ get_mlkem_public_key_bytes,
25
+ load_mlkem_public_key,
26
+ mlkem_private_key_from_hex,
27
+ mlkem_private_key_to_hex,
28
+ mlkem_public_key_from_json,
29
+ mlkem_public_key_to_json,
30
+ )
31
+ from .msgpack_utils import (
32
+ MSGPACK_DEFAULT_MAX_SIZE,
33
+ MSGPACK_MAX_ARRAY_LEN,
34
+ MSGPACK_MAX_BIN_LEN,
35
+ MSGPACK_MAX_EXT_LEN,
36
+ MSGPACK_MAX_MAP_LEN,
37
+ MSGPACK_MAX_STR_LEN,
38
+ msgpack_decode,
39
+ msgpack_encode,
40
+ validate_msgpack_size,
41
+ )
42
+
43
+ __all__ = [
44
+ # Key management
45
+ "KEY_ROTATION_WINDOW_DAYS",
46
+ "create_or_rotate_key_record",
47
+ "generate_mlkem_keypair",
48
+ "get_mlkem_public_key_bytes",
49
+ "load_mlkem_public_key",
50
+ "mlkem_private_key_from_hex",
51
+ "mlkem_private_key_to_hex",
52
+ "mlkem_public_key_from_json",
53
+ "mlkem_public_key_to_json",
54
+ # Encryption / decryption
55
+ "encrypt",
56
+ "decrypt",
57
+ # Msgpack utilities
58
+ "MSGPACK_DEFAULT_MAX_SIZE",
59
+ "MSGPACK_MAX_ARRAY_LEN",
60
+ "MSGPACK_MAX_BIN_LEN",
61
+ "MSGPACK_MAX_EXT_LEN",
62
+ "MSGPACK_MAX_MAP_LEN",
63
+ "MSGPACK_MAX_STR_LEN",
64
+ "msgpack_decode",
65
+ "msgpack_encode",
66
+ "validate_msgpack_size",
67
+ ]
iqcc_shared/crypto.py ADDED
@@ -0,0 +1,582 @@
1
+ """Cryptographic core: key management, path traversal, and symmetric encryption.
2
+
3
+ Implements:
4
+ • ML-KEM-768 key encapsulation (post-quantum)
5
+ • AES-256-GCM symmetric payload encryption
6
+ • Dot-notation path resolver with wildcard support
7
+ • Hybrid encrypt / decrypt round-trip
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import copy
13
+ import json
14
+ import os
15
+ import uuid
16
+ from datetime import UTC, datetime, timedelta
17
+ from typing import Any
18
+ from cryptography.hazmat.primitives.asymmetric import mlkem
19
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
20
+
21
+ from .msgpack_utils import (
22
+ MSGPACK_DEFAULT_MAX_SIZE,
23
+ msgpack_decode,
24
+ msgpack_encode,
25
+ validate_msgpack_size,
26
+ )
27
+
28
+ # ── Constants ──────────────────────────────────────────────────────────────
29
+
30
+ KEY_ROTATION_WINDOW_DAYS: int = 30
31
+ WIRE_FORMAT_VERSION: str = "1.0"
32
+ AES_KEY_SIZE: int = 32 # AES-256
33
+ GCM_NONCE_SIZE: int = 12 # 96-bit recommended
34
+
35
+
36
+ # ── ML-KEM-768 helpers ──────────────────────────────────────────────────
37
+
38
+
39
+ def generate_mlkem_keypair() -> tuple[
40
+ mlkem.MLKEM768PrivateKey, mlkem.MLKEM768PublicKey
41
+ ]:
42
+ """Generate a fresh ML-KEM-768 key pair.
43
+
44
+ Returns:
45
+ A tuple of (private_key, public_key).
46
+ """
47
+ private_key = mlkem.MLKEM768PrivateKey.generate()
48
+ public_key = private_key.public_key()
49
+ return private_key, public_key
50
+
51
+
52
+ def get_mlkem_public_key_bytes(public_key: mlkem.MLKEM768PublicKey) -> bytes:
53
+ """Serialize an ML-KEM-768 public key to raw bytes.
54
+
55
+ Args:
56
+ public_key: The ML-KEM-768 public key to serialize.
57
+
58
+ Returns:
59
+ The raw byte representation of the public key.
60
+ """
61
+ return public_key.public_bytes_raw()
62
+
63
+
64
+ def load_mlkem_public_key(raw_bytes: bytes) -> mlkem.MLKEM768PublicKey:
65
+ """Deserialize an ML-KEM-768 public key from raw bytes.
66
+
67
+ Args:
68
+ raw_bytes: The raw byte representation of an ML-KEM-768 public key.
69
+
70
+ Returns:
71
+ The reconstructed ML-KEM-768 public key object.
72
+ """
73
+ return mlkem.MLKEM768PublicKey.from_public_bytes(raw_bytes)
74
+
75
+
76
+ def mlkem_public_key_to_json(public_key: mlkem.MLKEM768PublicKey) -> str:
77
+ """Serialize an ML-KEM-768 public key to a JSON-encoded string.
78
+
79
+ The JSON payload contains ``format`` and ``key_hex`` (hex-encoded raw
80
+ bytes) so that public keys can be exchanged as plain JSON without any
81
+ msgpack coupling.
82
+
83
+ Args:
84
+ public_key: The ML-KEM-768 public key to serialize.
85
+
86
+ Returns:
87
+ A JSON string containing the public key data.
88
+ """
89
+ raw = public_key.public_bytes_raw()
90
+ payload = {"format": "ml-kem-768", "key_hex": raw.hex()}
91
+ return json.dumps(payload)
92
+
93
+
94
+ def mlkem_public_key_from_json(json_string: str) -> mlkem.MLKEM768PublicKey:
95
+ """Deserialize an ML-KEM-768 public key from a JSON-encoded string.
96
+
97
+ Args:
98
+ json_string: A JSON string produced by ``mlkem_public_key_to_json``.
99
+
100
+ Returns:
101
+ The reconstructed ML-KEM-768 public key object.
102
+
103
+ Raises:
104
+ ValueError: If the JSON format is not ``ml-kem-768`` or the hex
105
+ payload is invalid.
106
+ """
107
+ payload = json.loads(json_string)
108
+ if payload.get("format") != "ml-kem-768":
109
+ raise ValueError(f"Unsupported key format in JSON: {payload.get('format')!r}")
110
+ raw = bytes.fromhex(payload["key_hex"])
111
+ return mlkem.MLKEM768PublicKey.from_public_bytes(raw)
112
+
113
+
114
+ def mlkem_private_key_to_hex(private_key: mlkem.MLKEM768PrivateKey) -> str:
115
+ """Serialize an ML-KEM-768 private key to a hex-encoded string.
116
+
117
+ The hex string is safe for storage in Kubernetes secrets or other
118
+ string-compatible secret stores.
119
+
120
+ Args:
121
+ private_key: The ML-KEM-768 private key to serialize.
122
+
123
+ Returns:
124
+ A hex-encoded string of the raw private key bytes (64 bytes → 128
125
+ hex characters).
126
+ """
127
+ return private_key.private_bytes_raw().hex()
128
+
129
+
130
+ def mlkem_private_key_from_hex(hex_string: str) -> mlkem.MLKEM768PrivateKey:
131
+ """Deserialize an ML-KEM-768 private key from a hex-encoded string.
132
+
133
+ Args:
134
+ hex_string: A hex-encoded string produced by ``mlkem_private_key_to_hex``.
135
+
136
+ Returns:
137
+ The reconstructed ML-KEM-768 private key object.
138
+
139
+ Raises:
140
+ ValueError: If the hex string is invalid.
141
+ """
142
+ return mlkem.MLKEM768PrivateKey.from_seed_bytes(bytes.fromhex(hex_string))
143
+
144
+
145
+ # ── Key Record Creation & Rotation ───────────────────────────────────────
146
+
147
+
148
+ def _build_key_entry(
149
+ public_key: mlkem.MLKEM768PublicKey,
150
+ expires_at: datetime,
151
+ ) -> dict:
152
+ """Build a key entry dict with ``public_key`` stored as a JSON string.
153
+
154
+ Args:
155
+ public_key: The ML-KEM-768 public key for this entry.
156
+ expires_at: The expiry datetime of the key.
157
+
158
+ Returns:
159
+ A dict with ``key_id``, ``public_key``, ``public_key_format``,
160
+ and ``expires_at``.
161
+ """
162
+ return {
163
+ "key_id": str(uuid.uuid4()),
164
+ "public_key": mlkem_public_key_to_json(public_key),
165
+ "public_key_format": "ml-kem-768",
166
+ "expires_at": expires_at.isoformat(),
167
+ }
168
+
169
+
170
+ def create_or_rotate_key_record(
171
+ existing_record: dict | None = None,
172
+ validity_days: int = 180,
173
+ overlap_days: int = 30,
174
+ ) -> tuple[dict, str | None]:
175
+ """Create a fresh key record or rotate an existing one.
176
+
177
+ If *existing_record* is ``None`` a brand-new record is generated.
178
+ If the ``current`` key expires within ``KEY_ROTATION_WINDOW_DAYS``
179
+ a rotation is triggered: ``current → previous``, new key becomes
180
+ ``current``.
181
+
182
+ Args:
183
+ existing_record: Previously stored key record (from storage).
184
+ Pass ``None`` to create a fresh record.
185
+ validity_days: How many days the new key is valid from creation.
186
+ overlap_days: How many days the previous key remains valid
187
+ after rotation.
188
+
189
+ Returns:
190
+ A tuple of (key_record, private_key_hex). ``key_record`` is the
191
+ fully-formed dict ready for publishing. ``private_key_hex`` is a
192
+ hex-encoded string of the ML-KEM-768 private key corresponding to
193
+ the new ``current`` public key, suitable for storage in Kubernetes
194
+ secrets. It is ``None`` if no new key was generated
195
+ (i.e. the existing record was still valid and returned as-is).
196
+ """
197
+ now = datetime.now(UTC)
198
+
199
+ if existing_record is None:
200
+ priv, pub = generate_mlkem_keypair()
201
+ expires = now + timedelta(days=validity_days)
202
+ return {
203
+ "current": _build_key_entry(pub, expires),
204
+ "previous": None,
205
+ "rotation_policy": "auto",
206
+ "updated_at": now.isoformat(),
207
+ }, mlkem_private_key_to_hex(priv)
208
+
209
+ current_expiry = datetime.fromisoformat(
210
+ existing_record["current"]["expires_at"],
211
+ )
212
+ needs_rotation = (current_expiry - now).days <= KEY_ROTATION_WINDOW_DAYS
213
+
214
+ if not needs_rotation:
215
+ return existing_record, None
216
+
217
+ # ── Rotate ───────────────────────────────────────────────────
218
+ priv, pub = generate_mlkem_keypair()
219
+ new_expires = now + timedelta(days=validity_days)
220
+
221
+ old_current = dict(existing_record["current"])
222
+ old_current["expires_at"] = (now + timedelta(days=overlap_days)).isoformat()
223
+
224
+ return {
225
+ "current": _build_key_entry(pub, new_expires),
226
+ "previous": old_current,
227
+ "rotation_policy": "auto",
228
+ "updated_at": now.isoformat(),
229
+ }, mlkem_private_key_to_hex(priv)
230
+
231
+
232
+ # ── Path Traversal ───────────────────────────────────────────────────────
233
+
234
+
235
+ def _path_segments(path: str) -> list[str]:
236
+ """Split a dot-notation path into segments.
237
+
238
+ Args:
239
+ path: A dot-separated path string (e.g. ``auth.token``).
240
+
241
+ Returns:
242
+ A list of path segment strings.
243
+ """
244
+ return path.split(".")
245
+
246
+
247
+ def _resolve_path_values(
248
+ obj: Any,
249
+ paths: list[str],
250
+ _prefix: list[str] | None = None,
251
+ ) -> list[tuple[Any, str]]:
252
+ """Recursively find all leaf values matching any entry in *paths*.
253
+
254
+ Supports ``.*`` wildcard at the end of a path segment to match all
255
+ children at that level.
256
+
257
+ Args:
258
+ obj: The object (dict) to traverse.
259
+ paths: List of dot-notation path strings to match.
260
+ _prefix: Internal accumulator for the current path prefix.
261
+
262
+ Returns:
263
+ A list of ``(parent_container, key)`` tuples pointing to the
264
+ values that should be encrypted/decrypted.
265
+ """
266
+ if _prefix is None:
267
+ _prefix = []
268
+
269
+ matches: list[tuple[Any, str]] = []
270
+
271
+ if not isinstance(obj, dict):
272
+ return matches
273
+
274
+ for key in obj:
275
+ current_path = _prefix + [key]
276
+ current_value = obj[key]
277
+
278
+ # Check if current path matches any of the target paths
279
+ for target_path in paths:
280
+ segs = _path_segments(target_path)
281
+
282
+ # Case 1: exact match (path has same number of segments)
283
+ if len(segs) == len(current_path):
284
+ if _segments_match(segs, current_path):
285
+ matches.append((obj, key))
286
+ continue
287
+
288
+ # Case 2: target path is shorter (may have wildcard or is ancestor)
289
+ if len(segs) < len(current_path):
290
+ # Check if segments match (including wildcards)
291
+ if _segments_match(segs, current_path):
292
+ if current_value and isinstance(current_value, dict):
293
+ # Target might go deeper — recurse
294
+ sub_matches = _resolve_path_values(
295
+ current_value,
296
+ paths,
297
+ current_path,
298
+ )
299
+ if sub_matches:
300
+ matches.extend(sub_matches)
301
+ elif segs[-1] == "*":
302
+ # Wildcard at end: match all leaves
303
+ _collect_all_leaf_keys(current_value, matches)
304
+ elif current_value and isinstance(current_value, list):
305
+ _match_in_list(
306
+ current_value, segs, len(current_path), paths, matches
307
+ )
308
+ continue
309
+
310
+ # Case 3: target path is longer — this value must be a dict to descend
311
+ if len(segs) > len(current_path):
312
+ if _segments_match(segs[: len(current_path)], current_path):
313
+ if isinstance(current_value, dict):
314
+ sub = _resolve_path_values(current_value, paths, current_path)
315
+ matches.extend(sub)
316
+
317
+ return matches
318
+
319
+
320
+ def _segments_match(pattern_segs: list[str], actual_segs: list[str]) -> bool:
321
+ """Check if pattern segments match actual segments.
322
+
323
+ Supports ``*`` wildcard in any pattern position.
324
+
325
+ Args:
326
+ pattern_segs: List of pattern segments (may contain ``*``).
327
+ actual_segs: List of actual path segments to check.
328
+
329
+ Returns:
330
+ ``True`` if all segments match (wildcards match anything).
331
+ """
332
+ if len(pattern_segs) != len(actual_segs):
333
+ return False
334
+ for p, a in zip(pattern_segs, actual_segs):
335
+ if p != "*" and p != a:
336
+ return False
337
+ return True
338
+
339
+
340
+ def _collect_all_leaf_keys(
341
+ obj: Any,
342
+ matches: list[tuple[Any, str]],
343
+ _parent: Any | None = None,
344
+ _key: str | None = None,
345
+ ) -> None:
346
+ """Collect all leaf (non-dict) keys under *obj* into *matches*.
347
+
348
+ Args:
349
+ obj: The dict to collect leaves from.
350
+ matches: List to append ``(parent, key)`` tuples into.
351
+ _parent: Internal parent reference for nested traversal.
352
+ _key: Internal key name for nested traversal.
353
+ """
354
+ if not isinstance(obj, dict):
355
+ return
356
+ for key in obj:
357
+ value = obj[key]
358
+ if isinstance(value, dict) and value:
359
+ _collect_all_leaf_keys(value, matches, obj, key)
360
+ else:
361
+ matches.append((obj, key))
362
+
363
+
364
+ def _match_in_list(
365
+ lst: list,
366
+ target_segs: list[str],
367
+ current_depth: int,
368
+ paths: list[str],
369
+ matches: list[tuple[Any, str]],
370
+ ) -> None:
371
+ """Match paths within list elements.
372
+
373
+ Args:
374
+ lst: The list to traverse.
375
+ target_segs: Pattern segments from the target path.
376
+ current_depth: Current nesting depth.
377
+ paths: Full list of target paths.
378
+ matches: List to append ``(parent, key)`` tuples into.
379
+ """
380
+ for i, item in enumerate(lst):
381
+ if isinstance(item, dict):
382
+ sub = _resolve_path_values(item, paths)
383
+ matches.extend(sub)
384
+
385
+
386
+ # ── AES-256-GCM encryption / decryption ─────────────────────────────────
387
+
388
+
389
+ def _encrypt_value(plaintext: Any, key: bytes) -> dict:
390
+ """Encrypt *plaintext* with AES-256-GCM.
391
+
392
+ The plaintext is msgpack-encoded before encryption.
393
+
394
+ Args:
395
+ plaintext: The value to encrypt (any msgpack-serializable type).
396
+ key: The 32-byte AES-256 encryption key.
397
+
398
+ Returns:
399
+ A dict with ``encrypted``, ``nonce``, and ``tag`` keys.
400
+ """
401
+ nonce = os.urandom(GCM_NONCE_SIZE)
402
+ ciphertext_and_tag = AESGCM(key).encrypt(nonce, msgpack_encode(plaintext), None)
403
+ # AESGCM appends 16-byte tag at the end
404
+ ciphertext = ciphertext_and_tag[:-16]
405
+ tag = ciphertext_and_tag[-16:]
406
+ return {
407
+ "encrypted": ciphertext,
408
+ "nonce": nonce,
409
+ "tag": tag,
410
+ }
411
+
412
+
413
+ def _decrypt_value(token: dict, key: bytes) -> Any:
414
+ """Decrypt an AES-256-GCM encrypted token.
415
+
416
+ Args:
417
+ token: Dict with ``encrypted``, ``nonce``, and ``tag`` keys.
418
+ key: The 32-byte AES-256 encryption key.
419
+
420
+ Returns:
421
+ The original plaintext (msgpack-decoded).
422
+ """
423
+ ciphertext = token["encrypted"]
424
+ nonce = token["nonce"]
425
+ tag = token["tag"]
426
+ ciphertext_and_tag = ciphertext + tag
427
+ plaintext_bytes = AESGCM(key).decrypt(nonce, ciphertext_and_tag, None)
428
+ return msgpack_decode(plaintext_bytes)
429
+
430
+
431
+ # ── Public API: encrypt ──────────────────────────────────────────────────
432
+
433
+
434
+ def encrypt(
435
+ payload: dict,
436
+ paths: list[str],
437
+ target_public_key: mlkem.MLKEM768PublicKey | None = None,
438
+ target_key_id: str = "",
439
+ session_key: bytes | None = None,
440
+ ) -> tuple[bytes, bytes]:
441
+ """Encrypt selected fields of *payload*.
442
+
443
+ Process:
444
+ 1. Generate or use provided AES-256-GCM session key
445
+ 2. Resolve matching paths and encrypt those values
446
+ 3. Optionally encapsulate session key with ML-KEM-768
447
+ 4. Build message dict with metadata + modified payload
448
+ 5. Serialize to msgpack
449
+
450
+ Args:
451
+ payload: The dict containing data to encrypt.
452
+ paths: Dot-notation paths (supports ``.*`` wildcards) indicating
453
+ which fields to encrypt.
454
+ target_public_key: ML-KEM-768 public key for session key
455
+ encapsulation. Pass ``None`` when the receiver already holds
456
+ the session key (e.g. worker response path).
457
+ target_key_id: Identifier of the target public key used.
458
+ Ignored when ``target_public_key`` is ``None``.
459
+ session_key: Pre-generated AES-256-GCM session key to reuse.
460
+ When provided, it overrides any key from encapsulation.
461
+
462
+ Returns:
463
+ A tuple of ``(message_bytes, session_key)``. ``message_bytes`` is
464
+ the final msgpack-serialized ``bytes`` ready for transmission.
465
+ ``session_key`` is the 32-byte AES key used for encryption,
466
+ useful when the caller needs to store it for a later symmetric
467
+ reply (e.g. worker response path).
468
+ """
469
+ # 1. Resolve session key
470
+ encapsulated: bytes | None = None
471
+ if session_key is not None:
472
+ # Caller provided the key directly
473
+ resolved_session_key = session_key
474
+ elif target_public_key is not None:
475
+ # Encapsulate with target public key
476
+ resolved_session_key, encapsulated = target_public_key.encapsulate()
477
+ else:
478
+ # No encapsulation — generate a fresh key
479
+ resolved_session_key = os.urandom(AES_KEY_SIZE)
480
+
481
+ # 2. Deep-copy payload and encrypt matched values
482
+ encrypted_payload = copy.deepcopy(payload)
483
+ matches = _resolve_path_values(encrypted_payload, paths)
484
+ for container, key in matches:
485
+ container[key] = _encrypt_value(container[key], resolved_session_key)
486
+
487
+ # 3. Build metadata
488
+ metadata: dict = {
489
+ "encryption_metadata": {
490
+ "version": WIRE_FORMAT_VERSION,
491
+ "payload_encryption_algorithm": "AES-256-GCM",
492
+ "encrypted_paths": paths,
493
+ "timestamp": datetime.now(UTC).isoformat(),
494
+ },
495
+ "payload": encrypted_payload,
496
+ }
497
+
498
+ # Add encapsulation fields only when applicable
499
+ if encapsulated is not None:
500
+ metadata["encryption_metadata"]["key_encapsulation_algorithm"] = "ML-KEM-768"
501
+ metadata["encryption_metadata"]["target_public_key_id"] = target_key_id
502
+ metadata["encryption_metadata"]["encapsulated_session_key"] = encapsulated
503
+
504
+ return msgpack_encode(metadata), resolved_session_key
505
+
506
+
507
+ # ── Public API: decrypt ──────────────────────────────────────────────────
508
+
509
+
510
+ def decrypt(
511
+ message_bytes: bytes,
512
+ paths: list[str],
513
+ server_private_key: mlkem.MLKEM768PrivateKey | str | None = None,
514
+ session_key: bytes | None = None,
515
+ max_msgpack_len: int = MSGPACK_DEFAULT_MAX_SIZE,
516
+ ) -> tuple[dict, bytes]:
517
+ """Decrypt selected fields of a message.
518
+
519
+ Process:
520
+ 1. Validate message size
521
+ 2. Deserialize with strict limits
522
+ 3. Resolve session key (decapsulate or use directly)
523
+ 4. Decrypt matched values in the payload
524
+
525
+ Args:
526
+ message_bytes: Raw msgpack-encoded message from ``encrypt``.
527
+ paths: Same path list used during encryption.
528
+ server_private_key: ML-KEM-768 private key for decapsulating
529
+ the session key. Accepts either an ``MLKEM768PrivateKey``
530
+ object or a hex-encoded string (as returned by
531
+ ``create_or_rotate_key_record``). Required when decrypting
532
+ messages that contain ``encapsulated_session_key``.
533
+ session_key: Pre-resolved AES session key. Used when the message
534
+ has no encapsulation (e.g. worker response path) or to bypass
535
+ decapsulation entirely.
536
+ max_msgpack_len: Maximum allowed message size in bytes
537
+ (default 10 MiB).
538
+
539
+ Returns:
540
+ A tuple of (decrypted payload dict, 32-byte AES session key).
541
+
542
+ Raises:
543
+ ValueError: If the message has no encapsulation and no ``session_key``
544
+ is provided, or if the message size exceeds ``max_msgpack_len``.
545
+ """
546
+ # 1. Validate size
547
+ validate_msgpack_size(message_bytes, max_msgpack_len)
548
+
549
+ # 2. Decode
550
+ message = msgpack_decode(message_bytes)
551
+ metadata = message["encryption_metadata"]
552
+
553
+ # 3. Resolve session key
554
+ if session_key is not None:
555
+ resolved_key = session_key
556
+ elif "encapsulated_session_key" in metadata:
557
+ # Message contains encapsulation — decapsulate
558
+ if server_private_key is None:
559
+ raise ValueError(
560
+ "Message contains encapsulated session key but no "
561
+ "server_private_key was provided",
562
+ )
563
+ if isinstance(server_private_key, str):
564
+ server_private_key = mlkem_private_key_from_hex(server_private_key)
565
+ encapsulated = metadata["encapsulated_session_key"]
566
+ resolved_key = server_private_key.decapsulate(encapsulated)
567
+ else:
568
+ raise ValueError(
569
+ "Message has no encapsulated session key and no session_key "
570
+ "was provided. The receiver must provide the session_key "
571
+ "explicitly for symmetric-only messages.",
572
+ )
573
+
574
+ # 4. Decrypt matched values
575
+ payload = message["payload"]
576
+ matches = _resolve_path_values(payload, paths)
577
+ for container, key in matches:
578
+ token = container[key]
579
+ if isinstance(token, dict) and "encrypted" in token:
580
+ container[key] = _decrypt_value(token, resolved_key)
581
+
582
+ return payload, resolved_key
@@ -0,0 +1,99 @@
1
+ """msgpack serialization helpers with strict security hardening.
2
+
3
+ All public decode entry points enforce strict mode to prevent
4
+ memory exhaustion and type confusion attacks.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import msgpack
10
+
11
+ # ── Default security limits ────────────────────────────────────────────
12
+ MSGPACK_MAX_STR_LEN: int = 20 * 1024 * 1024 # 20 MiB
13
+ MSGPACK_MAX_BIN_LEN: int = 20 * 1024 * 1024 # 20 MiB
14
+ MSGPACK_MAX_ARRAY_LEN: int = 500_000
15
+ MSGPACK_MAX_MAP_LEN: int = 100_000
16
+ MSGPACK_MAX_EXT_LEN: int = 1_000
17
+ MSGPACK_DEFAULT_MAX_SIZE: int = 20 * 1024 * 1024 # 20 MiB
18
+
19
+ # ── Pre-built kwargs dicts ─────────────────────────────────────────────
20
+ DECODE_KWARGS: dict = {
21
+ "raw": False,
22
+ "strict_map_key": True,
23
+ "max_str_len": MSGPACK_MAX_STR_LEN,
24
+ "max_bin_len": MSGPACK_MAX_BIN_LEN,
25
+ "max_array_len": MSGPACK_MAX_ARRAY_LEN,
26
+ "max_map_len": MSGPACK_MAX_MAP_LEN,
27
+ "max_ext_len": MSGPACK_MAX_EXT_LEN,
28
+ }
29
+
30
+ ENCODE_KWARGS: dict = {
31
+ "use_bin_type": True,
32
+ }
33
+
34
+
35
+ # ── Public helpers ─────────────────────────────────────────────────────
36
+
37
+
38
+ def msgpack_encode(obj: object, **kwargs) -> bytes:
39
+ """Encode *obj* to msgpack bytes.
40
+
41
+ Extra keyword arguments override ``ENCODE_KWARGS`` defaults.
42
+
43
+ Args:
44
+ obj: The object to encode.
45
+ **kwargs: Additional keyword arguments passed to ``msgpack.packb``.
46
+
47
+ Returns:
48
+ The msgpack-encoded bytes.
49
+ """
50
+ merged = {**ENCODE_KWARGS, **kwargs}
51
+ return msgpack.packb(obj, **merged)
52
+
53
+
54
+ def msgpack_decode(
55
+ data: bytes,
56
+ **decode_overrides,
57
+ ) -> object:
58
+ """Decode *data* from msgpack bytes with strict security limits.
59
+
60
+ Extra keyword arguments override ``DECODE_KWARGS`` defaults
61
+ (e.g. to tighten *max_str_len* further).
62
+
63
+ Args:
64
+ data: The raw msgpack bytes to decode.
65
+ **decode_overrides: Additional keyword arguments passed to
66
+ ``msgpack.unpackb``.
67
+
68
+ Returns:
69
+ The decoded Python object.
70
+ """
71
+ merged = {**DECODE_KWARGS, **decode_overrides}
72
+ return msgpack.unpackb(data, **merged)
73
+
74
+
75
+ def validate_msgpack_size(
76
+ data: bytes,
77
+ max_size: int = MSGPACK_DEFAULT_MAX_SIZE,
78
+ ) -> bool:
79
+ """Validate that raw msgpack bytes do not exceed *max_size*.
80
+
81
+ Args:
82
+ data: The raw msgpack bytes to validate.
83
+ max_size: Maximum allowed size in bytes (default 20 MiB).
84
+
85
+ Returns:
86
+ ``True`` if the data is within the size limit.
87
+
88
+ Raises:
89
+ ValueError: If the data is empty or exceeds *max_size*.
90
+ """
91
+ if not data:
92
+ raise ValueError("msgpack data is empty")
93
+ size = len(data)
94
+ if size > max_size:
95
+ raise ValueError(
96
+ f"msgpack payload size ({size} bytes) exceeds maximum "
97
+ f"allowed size ({max_size} bytes)"
98
+ )
99
+ return True
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: iqcc-shared
3
+ Version: 0.1.0
4
+ Summary: Hybrid cryptographic utilities: ML-KEM-768 key encapsulation, ML-DSA-65 signing, AES-256-GCM payload encryption, and msgpack serialization
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: cryptography>=48.0.0
7
+ Requires-Dist: msgpack>=1.0.8
@@ -0,0 +1,6 @@
1
+ iqcc_shared/__init__.py,sha256=b2YhM_SAITAGupJ_oGH7OrY70mSdAndPZd3eQlR5kNU,1952
2
+ iqcc_shared/crypto.py,sha256=5KFmHm-ghlvnXJ4iJnUa-1IbnnBdSfkrWdbAS5UXXnE,20623
3
+ iqcc_shared/msgpack_utils.py,sha256=B0oJlnnLqK3H0xJ1Y7T1ePwLXwSHZjgEEq6gy2S6cwo,3062
4
+ iqcc_shared-0.1.0.dist-info/METADATA,sha256=30PVSZ4jhWvbP-_vSDU2zqupi4GwUDokw-0gySTZQcw,293
5
+ iqcc_shared-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
6
+ iqcc_shared-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any