|
19 | 19 | XWING_ENCAPSULATION_KEY_SIZE = 1216 # public key, bytes |
20 | 20 | XWING_CIPHERTEXT_SIZE = 1120 # KEM ciphertext (wrappedKey), bytes |
21 | 21 |
|
| 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 | + |
22 | 28 |
|
23 | 29 | def _b64_decoded_len(s: str) -> int: |
24 | 30 | """Return the byte length of a base64-encoded string.""" |
@@ -231,6 +237,26 @@ def test_secpmlkem_3_roundtrip( |
231 | 237 | assert filecmp.cmp(pt_file, rt_file) |
232 | 238 |
|
233 | 239 |
|
| 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 | + |
234 | 260 | def test_secpmlkem_5_roundtrip( |
235 | 261 | attribute_with_secpmlkem_5_key: tuple[Attribute, list[str]], |
236 | 262 | key_secpmlkem_5: KasKey, |
@@ -287,3 +313,105 @@ def test_secpmlkem_5_roundtrip( |
287 | 313 | rt_file = encrypted_tdf.rt_file(ct_file, decrypt_sdk) |
288 | 314 | decrypt_sdk.decrypt(ct_file, rt_file, "ztdf") |
289 | 315 | 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