Skip to content

Commit 4a2e7fa

Browse files
feat(xtest): Adds pure mlkem test scenarios
1 parent 5a499cc commit 4a2e7fa

6 files changed

Lines changed: 213 additions & 4 deletions

File tree

xtest/abac.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,8 @@ class KasGrantValue(BaseModelIgnoreExtra):
173173
"hpqt:xwing",
174174
"hpqt:secp256r1-mlkem768",
175175
"hpqt:secp384r1-mlkem1024",
176+
"mlkem:768",
177+
"mlkem:1024",
176178
]
177179

178180
KAS_PUBLIC_KEY_ALG_ENUM_RSA_2048 = 1
@@ -186,6 +188,10 @@ class KasGrantValue(BaseModelIgnoreExtra):
186188
KAS_PUBLIC_KEY_ALG_ENUM_HPQT_SECP256R1_MLKEM768 = 11
187189
KAS_PUBLIC_KEY_ALG_ENUM_HPQT_SECP384R1_MLKEM1024 = 12
188190

191+
# Pure ML-KEM enums match protobuf ALGORITHM_MLKEM_768/1024 in platform PR #3537.
192+
KAS_PUBLIC_KEY_ALG_ENUM_MLKEM_768 = 20
193+
KAS_PUBLIC_KEY_ALG_ENUM_MLKEM_1024 = 21
194+
189195
_KAS_ALG_TO_STR_MAP = {
190196
KAS_PUBLIC_KEY_ALG_ENUM_RSA_2048: "rsa:2048",
191197
KAS_PUBLIC_KEY_ALG_ENUM_RSA_4096: "rsa:4096",
@@ -195,6 +201,8 @@ class KasGrantValue(BaseModelIgnoreExtra):
195201
KAS_PUBLIC_KEY_ALG_ENUM_HPQT_XWING: "hpqt:xwing",
196202
KAS_PUBLIC_KEY_ALG_ENUM_HPQT_SECP256R1_MLKEM768: "hpqt:secp256r1-mlkem768",
197203
KAS_PUBLIC_KEY_ALG_ENUM_HPQT_SECP384R1_MLKEM1024: "hpqt:secp384r1-mlkem1024",
204+
KAS_PUBLIC_KEY_ALG_ENUM_MLKEM_768: "mlkem:768",
205+
KAS_PUBLIC_KEY_ALG_ENUM_MLKEM_1024: "mlkem:1024",
198206
}
199207
_STR_TO_KAS_ALG_MAP = {v: k for k, v in _KAS_ALG_TO_STR_MAP.items()}
200208

xtest/conftest.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -173,12 +173,13 @@ def pytest_configure(config: pytest.Config) -> None:
173173
# If the user passed --sdks-encrypt / --sdks-decrypt explicitly, their
174174
# tokens win and we skip the resolution step entirely.
175175
need_resolve = (
176-
(not config.getoption("--sdks-encrypt") and scenario.sdks.encrypt)
177-
or (not config.getoption("--sdks-decrypt") and scenario.sdks.decrypt)
178-
)
176+
not config.getoption("--sdks-encrypt") and scenario.sdks.encrypt
177+
) or (not config.getoption("--sdks-decrypt") and scenario.sdks.decrypt)
179178
if need_resolve:
180179
try:
181-
tokens = scenario_to_pytest_sdks(scenario, installed_json_for(scenario_path))
180+
tokens = scenario_to_pytest_sdks(
181+
scenario, installed_json_for(scenario_path)
182+
)
182183
except FileNotFoundError as e:
183184
raise pytest.UsageError(str(e)) from e
184185
if not config.getoption("--sdks-encrypt") and tokens["encrypt"]:

xtest/fixtures/keys.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,30 @@ def key_secpmlkem_5(
261261
)
262262

263263

264+
@pytest.fixture(scope="module")
265+
def key_mlkem_768(
266+
otdfctl: OpentdfCommandLineTool,
267+
kas_entry_km1: abac.KasEntry,
268+
root_key: str,
269+
) -> abac.KasKey:
270+
"""Get or create pure ML-KEM-768 managed key 'm1' on km1."""
271+
return _get_or_create_key(
272+
otdfctl, kas_entry_km1, "m1", "mlkem:768", root_key, "mechanism-mlkem"
273+
)
274+
275+
276+
@pytest.fixture(scope="module")
277+
def key_mlkem_1024(
278+
otdfctl: OpentdfCommandLineTool,
279+
kas_entry_km1: abac.KasEntry,
280+
root_key: str,
281+
) -> abac.KasKey:
282+
"""Get or create pure ML-KEM-1024 managed key 'm2' on km1."""
283+
return _get_or_create_key(
284+
otdfctl, kas_entry_km1, "m2", "mlkem:1024", root_key, "mechanism-mlkem"
285+
)
286+
287+
264288
# ---------------------------------------------------------------------------
265289
# Attribute + key assignment fixtures (value-level)
266290
# ---------------------------------------------------------------------------
@@ -385,6 +409,42 @@ def attribute_with_secpmlkem_5_key(
385409
)
386410

387411

412+
@pytest.fixture(scope="module")
413+
def attribute_with_mlkem_768_key(
414+
otdfctl: OpentdfCommandLineTool,
415+
key_mlkem_768: abac.KasKey,
416+
otdf_client_scs: abac.SubjectConditionSet,
417+
temporary_namespace: abac.Namespace,
418+
) -> tuple[abac.Attribute, list[str]]:
419+
"""ALL_OF attribute with one pure ML-KEM-768 key assigned at value level."""
420+
return _create_keyed_attribute(
421+
otdfctl,
422+
temporary_namespace,
423+
"mlkem768-test",
424+
[("m1", key_mlkem_768)],
425+
otdf_client_scs,
426+
"mechanism-mlkem",
427+
)
428+
429+
430+
@pytest.fixture(scope="module")
431+
def attribute_with_mlkem_1024_key(
432+
otdfctl: OpentdfCommandLineTool,
433+
key_mlkem_1024: abac.KasKey,
434+
otdf_client_scs: abac.SubjectConditionSet,
435+
temporary_namespace: abac.Namespace,
436+
) -> tuple[abac.Attribute, list[str]]:
437+
"""ALL_OF attribute with one pure ML-KEM-1024 key assigned at value level."""
438+
return _create_keyed_attribute(
439+
otdfctl,
440+
temporary_namespace,
441+
"mlkem1024-test",
442+
[("m2", key_mlkem_1024)],
443+
otdf_client_scs,
444+
"mechanism-mlkem",
445+
)
446+
447+
388448
# ---------------------------------------------------------------------------
389449
# Attribute + key assignment fixture (attribute-level)
390450
# ---------------------------------------------------------------------------

xtest/sdk/go/cli.sh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,11 @@ if [ "$1" == "supports" ]; then
110110
"${cmd[@]}" help policy kas-registry key create | grep -i hpqt:secp256r1-mlkem768
111111
exit $?
112112
;;
113+
mechanism-mlkem)
114+
set -o pipefail
115+
"${cmd[@]}" help policy kas-registry key create | grep -iE 'mlkem:768|mlkem:1024'
116+
exit $?
117+
;;
113118
*)
114119
echo "Unknown feature: $2"
115120
exit 2

xtest/tdfs.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ def is_sdk_type(val: str) -> TypeIs[sdk_type]:
6060
"mechanism-xwing",
6161
# Support for encrypting with hybrid post-quantum/traditional KEM with NIST Elliptic Curves.
6262
"mechanism-secpmlkem",
63+
# Support for pure (non-hybrid) ML-KEM key wrapping: mlkem:768 and mlkem:1024.
64+
"mechanism-mlkem",
6365
"ns_grants",
6466
"obligations",
6567
]
@@ -143,6 +145,11 @@ def __init__(self, **kwargs: dict[str, Any]):
143145
self.features.add("mechanism-xwing")
144146
self.features.add("mechanism-secpmlkem")
145147

148+
# Pure ML-KEM (non-hybrid) added by platform PR #3537. Tentatively v0.17.0;
149+
# bump when the release target is finalized.
150+
if self.semver >= (0, 17, 0):
151+
self.features.add("mechanism-mlkem")
152+
146153
print(f"PLATFORM_VERSION '{v}' supports [{', '.join(self.features)}]")
147154

148155
def skip_if_unsupported(self, *features: feature_type):

xtest/test_pqc.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@
1919
XWING_ENCAPSULATION_KEY_SIZE = 1216 # public key, bytes
2020
XWING_CIPHERTEXT_SIZE = 1120 # KEM ciphertext (wrappedKey), bytes
2121

22+
# Pure ML-KEM sizes per FIPS 203 §8.
23+
MLKEM768_ENCAPSULATION_KEY_SIZE = 1184
24+
MLKEM768_CIPHERTEXT_SIZE = 1088
25+
MLKEM1024_ENCAPSULATION_KEY_SIZE = 1568
26+
MLKEM1024_CIPHERTEXT_SIZE = 1568
27+
2228

2329
def _b64_decoded_len(s: str) -> int:
2430
"""Return the byte length of a base64-encoded string."""
@@ -231,6 +237,26 @@ def test_secpmlkem_3_roundtrip(
231237
assert filecmp.cmp(pt_file, rt_file)
232238

233239

240+
def _assert_mlkem_kao(
241+
kao: KeyAccessObject,
242+
expected_kids: set[str],
243+
expected_url: str,
244+
min_ciphertext_size: int,
245+
) -> None:
246+
assert kao.kid in expected_kids
247+
assert kao.url == expected_url
248+
# PR #3537 currently emits the legacy "wrapped" type for pure ML-KEM. Some
249+
# roadmap notes mention "mlkem-wrapped"; accept either so the test records
250+
# which one ships without breaking on a naming change.
251+
assert kao.type in {"wrapped", "mlkem-wrapped"}, (
252+
f"unexpected KAO type for ML-KEM: {kao.type!r}"
253+
)
254+
wrapped_len = _b64_decoded_len(kao.wrappedKey)
255+
assert wrapped_len > min_ciphertext_size, (
256+
f"wrappedKey should exceed raw ML-KEM ciphertext ({min_ciphertext_size}), got {wrapped_len}"
257+
)
258+
259+
234260
def test_secpmlkem_5_roundtrip(
235261
attribute_with_secpmlkem_5_key: tuple[Attribute, list[str]],
236262
key_secpmlkem_5: KasKey,
@@ -287,3 +313,105 @@ def test_secpmlkem_5_roundtrip(
287313
rt_file = encrypted_tdf.rt_file(ct_file, decrypt_sdk)
288314
decrypt_sdk.decrypt(ct_file, rt_file, "ztdf")
289315
assert filecmp.cmp(pt_file, rt_file)
316+
317+
318+
def test_mlkem_768_roundtrip(
319+
attribute_with_mlkem_768_key: tuple[Attribute, list[str]],
320+
key_mlkem_768: KasKey,
321+
encrypt_sdk: tdfs.SDK,
322+
decrypt_sdk: tdfs.SDK,
323+
pt_file: Path,
324+
kas_url_km1: str,
325+
in_focus: set[tdfs.SDK],
326+
encrypted_tdf: EncryptFactory,
327+
):
328+
"""Encrypt with a pure ML-KEM-768 managed key, then attempt decrypt.
329+
330+
The decrypt SDK is intentionally NOT pre-skipped on `mechanism-mlkem` so
331+
we can observe whether SDKs lacking explicit pure-mlkem support can still
332+
process the new ``mlkem-wrapped`` KAO type.
333+
"""
334+
if not in_focus & {encrypt_sdk, decrypt_sdk}:
335+
pytest.skip("Not in focus")
336+
pfs = tdfs.get_platform_features()
337+
pfs.skip_if_unsupported("key_management", "autoconfigure", "mechanism-mlkem")
338+
encrypt_sdk.skip_if_unsupported(
339+
"key_management", "autoconfigure", "mechanism-mlkem"
340+
)
341+
tdfs.skip_connectrpc_skew(encrypt_sdk, decrypt_sdk, pfs)
342+
tdfs.skip_hexless_skew(encrypt_sdk, decrypt_sdk)
343+
344+
attr, key_ids = attribute_with_mlkem_768_key
345+
346+
ct_file = encrypted_tdf(
347+
encrypt_sdk,
348+
attr_values=attr.value_fqns,
349+
target_mode=tdfs.select_target_version(encrypt_sdk, decrypt_sdk),
350+
)
351+
352+
manifest = tdfs.manifest(ct_file)
353+
assert len(manifest.encryptionInformation.keyAccess) == 1
354+
_assert_mlkem_kao(
355+
manifest.encryptionInformation.keyAccess[0],
356+
expected_kids=set(key_ids),
357+
expected_url=kas_url_km1,
358+
min_ciphertext_size=MLKEM768_CIPHERTEXT_SIZE,
359+
)
360+
der_len = _pem_decoded_len(key_mlkem_768.key.public_key_ctx.pem)
361+
assert der_len >= MLKEM768_ENCAPSULATION_KEY_SIZE, (
362+
f"public key DER should be >= {MLKEM768_ENCAPSULATION_KEY_SIZE} bytes, got {der_len}"
363+
)
364+
365+
rt_file = encrypted_tdf.rt_file(ct_file, decrypt_sdk)
366+
decrypt_sdk.decrypt(ct_file, rt_file, "ztdf")
367+
assert filecmp.cmp(pt_file, rt_file)
368+
369+
370+
def test_mlkem_1024_roundtrip(
371+
attribute_with_mlkem_1024_key: tuple[Attribute, list[str]],
372+
key_mlkem_1024: KasKey,
373+
encrypt_sdk: tdfs.SDK,
374+
decrypt_sdk: tdfs.SDK,
375+
pt_file: Path,
376+
kas_url_km1: str,
377+
in_focus: set[tdfs.SDK],
378+
encrypted_tdf: EncryptFactory,
379+
):
380+
"""Encrypt with a pure ML-KEM-1024 managed key, then attempt decrypt.
381+
382+
See ``test_mlkem_768_roundtrip`` for the decrypt-SDK skip rationale.
383+
"""
384+
if not in_focus & {encrypt_sdk, decrypt_sdk}:
385+
pytest.skip("Not in focus")
386+
pfs = tdfs.get_platform_features()
387+
pfs.skip_if_unsupported("key_management", "autoconfigure", "mechanism-mlkem")
388+
encrypt_sdk.skip_if_unsupported(
389+
"key_management", "autoconfigure", "mechanism-mlkem"
390+
)
391+
tdfs.skip_connectrpc_skew(encrypt_sdk, decrypt_sdk, pfs)
392+
tdfs.skip_hexless_skew(encrypt_sdk, decrypt_sdk)
393+
394+
attr, key_ids = attribute_with_mlkem_1024_key
395+
396+
ct_file = encrypted_tdf(
397+
encrypt_sdk,
398+
attr_values=attr.value_fqns,
399+
target_mode=tdfs.select_target_version(encrypt_sdk, decrypt_sdk),
400+
)
401+
402+
manifest = tdfs.manifest(ct_file)
403+
assert len(manifest.encryptionInformation.keyAccess) == 1
404+
_assert_mlkem_kao(
405+
manifest.encryptionInformation.keyAccess[0],
406+
expected_kids=set(key_ids),
407+
expected_url=kas_url_km1,
408+
min_ciphertext_size=MLKEM1024_CIPHERTEXT_SIZE,
409+
)
410+
der_len = _pem_decoded_len(key_mlkem_1024.key.public_key_ctx.pem)
411+
assert der_len >= MLKEM1024_ENCAPSULATION_KEY_SIZE, (
412+
f"public key DER should be >= {MLKEM1024_ENCAPSULATION_KEY_SIZE} bytes, got {der_len}"
413+
)
414+
415+
rt_file = encrypted_tdf.rt_file(ct_file, decrypt_sdk)
416+
decrypt_sdk.decrypt(ct_file, rt_file, "ztdf")
417+
assert filecmp.cmp(pt_file, rt_file)

0 commit comments

Comments
 (0)