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.
- iqcc_shared/__init__.py +67 -0
- iqcc_shared/crypto.py +582 -0
- iqcc_shared/msgpack_utils.py +99 -0
- iqcc_shared-0.1.0.dist-info/METADATA +7 -0
- iqcc_shared-0.1.0.dist-info/RECORD +6 -0
- iqcc_shared-0.1.0.dist-info/WHEEL +4 -0
iqcc_shared/__init__.py
ADDED
|
@@ -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,,
|