diff --git a/spec/DSPX-4221.md b/spec/DSPX-4221.md new file mode 100644 index 00000000..329055f2 --- /dev/null +++ b/spec/DSPX-4221.md @@ -0,0 +1,66 @@ +--- +ticket: DSPX-4221 +title: Session Keys should support ML-KEM +status: in-review +authors: + - dmihalcik@virtru.com +branches: + - opentdf/tests:DSPX-4221-pq-sessions + - opentdf/platform:DSPX-4221-pq-sessions + - opentdf/java-sdk:DSPX-4221-pq-sessions + - opentdf/web-sdk:DSPX-4221-pq-sessions +prs: + - opentdf/tests#571 + - opentdf/platform#3814 + - opentdf/java-sdk#388 + - opentdf/web-sdk#975 +created: 2026-07-31T00:00:00Z +updated: 2026-08-01T00:00:00Z +jira_priority: Medium +--- + +# Session Keys should support ML-KEM + +## Summary + +Make sure all clients (and the server) support ML-KEM as the session encryption key (client-generated key pair). + +## Problem / Motivation + +The rewrap "session key" is the ephemeral key pair a client generates and sends as `clientPublicKey` on a rewrap request, so KAS can wrap the response DEK back to the client. This is a separate concept from the KAS-managed TDF/KAO wrapping key (the `mechanism-mlkem`/`mechanism-xwing`/`mechanism-secpmlkem` features), which already supports post-quantum algorithms. Before this work, the session-key channel only supported RSA and EC, so the rewrap *transport* remained a classical-crypto dependency even for a client and KAS that had otherwise fully adopted PQC-safe wrapping keys — a gap for future-proofing against a cryptographically-relevant quantum computer. + +## Proposed Solution + +- **Platform (KAS)**: accept pure ML-KEM-768/1024 SPKI client public keys in rewrap, gated behind the same preview flag used for KAS-managed ML-KEM support. +- **Go, Java, and Web SDKs**: generate an ML-KEM ephemeral session key on request and decapsulate the corresponding rewrap response. +- **xtest**: a new `session-key-mlkem` feature flag and `test_session_key_mlkem_roundtrip`, which asserts against the KAS rewrap audit log's `sessionKeyType` field rather than just checking that decrypt succeeded — a successful roundtrip alone doesn't prove ML-KEM was actually negotiated, since a client silently falling back to RSA and a server responding in kind would still "work." This test is what caught (and led to a fix for) a real Web SDK bug where the requested session-key algorithm was silently dropped. The platform side of this flag currently reuses the pre-existing `Preview.MLKEMTDFEnabled`/KAS-managed-mechanism probe rather than a dedicated session-key readiness check; each SDK's own hardcoded `session-key-mlkem` capability flag is what actually gates the test on the fix landing (see the comment on `tdfs.py`'s feature-detection block for the known imprecision this leaves on the platform side alone). + +## Inputs / Outputs / Contracts + +- Client sends `clientPublicKey` as a PEM-encoded SPKI public key on the rewrap request; the server infers the session-key type from the SPKI's algorithm OID (there is no explicit "key type" field on the request). +- New CLI/API surface accepting `mlkem:768` / `mlkem:1024`: + - `otdfctl decrypt --session-key-algorithm mlkem:768` + - Go SDK: `sdk.WithSessionKeyType(ocrypto.MLKEM768Key)` (or `ocrypto.MLKEM1024Key`) + - Java cmdline: `--rewrap-key-type mlkem:768` + - Web SDK CLI: `--rewrapKeyType mlkem:768` +- New audit field: `eventMetaData.sessionKeyType` on KAS rewrap audit events, recording the negotiated session-key type independently of anything the client reports about itself. +- xtest: `SDK.decrypt(session_key_algorithm=...)`, threaded through `XT_WITH_SESSION_KEY_ALGORITHM` to each SDK CLI wrapper; `audit_logs.assert_rewrap_success(session_key_type=...)` for verifying the negotiated type. + +## Edge Cases & Constraints + +- Scope is limited to pure ML-KEM (768/1024); hybrid PQ/T session keys (X-Wing, secp+ML-KEM composites) are explicitly out of scope for this ticket. +- Gated behind the platform's ML-KEM preview flag; a platform without it enabled rejects an ML-KEM `clientPublicKey` the same way it always rejected any non-RSA/EC key. +- The session-key algorithm is independent of the TDF's own KAO wrapping mechanism: an RSA-wrapped TDF can be rewrapped over an ML-KEM session key (verified via `test_session_key_mlkem_roundtrip`, which deliberately uses a plain RSA-wrapped attribute). + +## Out of Scope + +- Hybrid PQ/T session keys (X-Wing, NIST-hybrid EC+ML-KEM composites). +- Changes to the KAO/TDF wrapping mechanism itself (`mechanism-mlkem`, etc.), which already existed before this work. +- NanoTDF session keys. + +## Acceptance Criteria + +- [x] KAS rewrap accepts ML-KEM-768/1024 client session keys, gated by the platform's ML-KEM preview flag. +- [x] Go SDK, Java SDK, and Web SDK can each generate an ML-KEM session key and successfully decrypt a rewrap response wrapped to it. +- [x] Cross-SDK interop verified: any encrypt SDK paired with any decrypt SDK, for both `mlkem:768` and `mlkem:1024`. +- [x] xtest coverage asserts the negotiated session-key type via the KAS audit log, not just roundtrip success. diff --git a/xtest/audit_logs.py b/xtest/audit_logs.py index c894110c..caed3e1a 100644 --- a/xtest/audit_logs.py +++ b/xtest/audit_logs.py @@ -436,6 +436,16 @@ def algorithm(self) -> str | None: """Get the algorithm from rewrap event metadata.""" return self.event_metadata.get("algorithm") + @property + def session_key_type(self) -> str | None: + """Get the client's rewrap session-key type from rewrap event metadata. + + This is the ephemeral key the client generated and sent as + clientPublicKey (e.g. "rsa:2048", "ec:secp256r1", "mlkem:768") -- + distinct from `algorithm`, which is the KAO/TDF wrapping algorithm. + """ + return self.event_metadata.get("sessionKeyType") + @property def tdf_format(self) -> str | None: """Get the TDF format from rewrap event metadata.""" @@ -467,6 +477,7 @@ def matches_rewrap( policy_uuid: str | None = None, key_id: str | None = None, algorithm: str | None = None, + session_key_type: str | None = None, attr_fqns: list[str] | None = None, ) -> bool: """Check if this event matches rewrap criteria. @@ -476,6 +487,7 @@ def matches_rewrap( policy_uuid: Expected policy UUID (object ID) key_id: Expected key ID from metadata algorithm: Expected algorithm from metadata + session_key_type: Expected client session-key type from metadata attr_fqns: Expected attribute FQNs (all must be present) Returns: @@ -491,6 +503,8 @@ def matches_rewrap( return False if algorithm is not None and self.algorithm != algorithm: return False + if session_key_type is not None and self.session_key_type != session_key_type: + return False if attr_fqns is not None: event_attrs = set(self.object_attrs) if not all(fqn in event_attrs for fqn in attr_fqns): @@ -1254,6 +1268,7 @@ def assert_rewrap( policy_uuid: str | None = None, key_id: str | None = None, algorithm: str | None = None, + session_key_type: str | None = None, attr_fqns: list[str] | None = None, min_count: int = 1, since_mark: str | None = None, @@ -1264,13 +1279,16 @@ def assert_rewrap( Looks for audit log entries with: - msg='rewrap' - action.result= - - Optionally matching policy_uuid, key_id, algorithm, attr_fqns + - Optionally matching policy_uuid, key_id, algorithm, session_key_type, attr_fqns Args: result: Expected action result ('success', 'failure', 'error', 'cancel') policy_uuid: Expected policy UUID (object.id) key_id: Expected key ID from eventMetaData.keyID algorithm: Expected algorithm from eventMetaData.algorithm + session_key_type: Expected client session-key type from + eventMetaData.sessionKeyType (e.g. "mlkem:768") -- the + client's ephemeral rewrap key, not the KAO wrap algorithm attr_fqns: Expected attribute FQNs (all must be present) min_count: Minimum number of matching entries (default: 1) since_mark: Only check logs since marked timestamp @@ -1306,6 +1324,7 @@ def assert_rewrap( policy_uuid=policy_uuid, key_id=key_id, algorithm=algorithm, + session_key_type=session_key_type, attr_fqns=attr_fqns, ): matching.append(event) @@ -1331,6 +1350,8 @@ def assert_rewrap( criteria.append(f"key_id={key_id}") if algorithm: criteria.append(f"algorithm={algorithm}") + if session_key_type: + criteria.append(f"session_key_type={session_key_type}") if attr_fqns: criteria.append(f"attr_fqns={attr_fqns}") @@ -1349,6 +1370,7 @@ def assert_rewrap_success( policy_uuid: str | None = None, key_id: str | None = None, algorithm: str | None = None, + session_key_type: str | None = None, attr_fqns: list[str] | None = None, min_count: int = 1, since_mark: str | None = None, @@ -1363,17 +1385,93 @@ def assert_rewrap_success( policy_uuid=policy_uuid, key_id=key_id, algorithm=algorithm, + session_key_type=session_key_type, attr_fqns=attr_fqns, min_count=min_count, since_mark=since_mark, timeout=timeout, ) + def assert_rewrap_session_key_type( + self, + expected: str, + since_mark: str | None = None, + min_count: int = 1, + timeout: float = 20.0, + ) -> None: + """Assert a successful rewrap negotiated the expected client session-key type. + + Tolerant of platform builds that don't emit eventMetaData.sessionKeyType + at all: that field was added by DSPX-4221 and isn't behind any version + or preview flag we can check statically, so a plain + assert_rewrap_success(session_key_type=expected) call would spuriously + find zero matches (not "wrong type") against a baseline/pre-fix + platform build -- indistinguishable, from the caller's perspective, + from a real negotiation bug. + + Waits (up to `timeout`) for an event matching the expected type first, + rather than accepting the first bare "result=success" match: log + collection is poll-based, so when two rewraps for different session + keys happen back-to-back in the same test (e.g. a plain decrypt + immediately followed by one requesting a specific algorithm), the + earlier rewrap's log line can still be un-tailed at the time of the + later mark and get folded into the same collection batch -- a bare + count check would then report success using the wrong (earlier) + event instead of waiting for the right one. Only after that wait + fails do we check whether the field is present at all, to tell + "platform doesn't support this field" apart from a real negotiation + bug. + + TODO(DSPX-4221): once the platform PR that adds sessionKeyType + (opentdf/platform#3814) merges and cuts a release, add a + version-gated "audit-session-key-type" feature to PlatformFeatureSet + (mirroring the existing `audit_logging` gate: `self.semver >= (0, 10, + 0)`), and have callers hard-fail here instead of warning when + `"audit-session-key-type" in pfs.features` -- there's no way to + detect this statically before that release exists, so until then a + platform build too old to emit the field is indistinguishable, to + this method, from one new enough but paired with a client that + silently sends the wrong session-key type. That gap is real, not + hypothetical: it currently masks an unpatched web-sdk (main) sending + the wrong session-key algorithm on EC rewrap when paired with a + platform build that does emit the field -- see the stacked draft PR + demonstrating this failure. + """ + try: + self.assert_rewrap( + result="success", + session_key_type=expected, + min_count=min_count, + since_mark=since_mark, + timeout=timeout, + ) + return + except AssertionError: + pass + + events = self.assert_rewrap_success( + min_count=min_count, since_mark=since_mark, timeout=1.0 + ) + reported = { + e.session_key_type for e in events if e.session_key_type is not None + } + if not reported: + logger.warning( + "Platform build doesn't emit eventMetaData.sessionKeyType; " + f"skipping the check that the session key type was {expected!r} " + "(the rewrap itself succeeded)." + ) + return + assert expected in reported, ( + f"Expected rewrap session_key_type={expected!r}, but platform reported {reported!r}" + ) + def assert_rewrap_failure( self, policy_uuid: str | None = None, key_id: str | None = None, algorithm: str | None = None, + session_key_type: str | None = None, attr_fqns: list[str] | None = None, min_count: int = 1, since_mark: str | None = None, @@ -1389,6 +1487,7 @@ def assert_rewrap_failure( policy_uuid=policy_uuid, key_id=key_id, algorithm=algorithm, + session_key_type=session_key_type, attr_fqns=attr_fqns, min_count=min_count, since_mark=since_mark, diff --git a/xtest/sdk/go/cli.sh b/xtest/sdk/go/cli.sh index 91195bf0..46baeedf 100755 --- a/xtest/sdk/go/cli.sh +++ b/xtest/sdk/go/cli.sh @@ -11,12 +11,13 @@ # # Extended Configuration: # XT_WITH_ECDSA_BINDING [boolean] - Use ECDSA binding for encryption -# XT_WITH_ECWRAP [boolean] - Use EC wrap for encryption/decryption +# XT_WITH_ECWRAP [boolean] - Use EC wrap for the TDF's own KAO wrapping key on encryption (use XT_WITH_SESSION_KEY_ALGORITHM for decryption) # XT_WITH_VERIFY_ASSERTIONS [boolean] - Verify assertions during decryption # XT_WITH_ASSERTIONS [string] - Path to assertions file, or JSON encoded as string # XT_WITH_ASSERTION_VERIFICATION_KEYS [string] - Path to assertion verification private key file # XT_WITH_ATTRIBUTES [string] - Attributes to be used for encryption # XT_WITH_MIME_TYPE [string] - MIME type for the encrypted file +# XT_WITH_SESSION_KEY_ALGORITHM [string] - Rewrap session key algorithm for decryption (e.g. mlkem:768) # SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd) @@ -115,6 +116,11 @@ if [ "$1" == "supports" ]; then "${cmd[@]}" help policy kas-registry key create | grep -iE 'mlkem:768|mlkem:1024' exit $? ;; + session-key-mlkem) + set -o pipefail + "${cmd[@]}" help decrypt | grep -iE 'mlkem:768|mlkem:1024' + exit $? + ;; dpop | dpop_nonce_challenge) set -o pipefail "${cmd[@]}" --version --json | jq -e --arg f "$2" '.supported_features | @@ -197,8 +203,8 @@ elif [ "$1" == "decrypt" ]; then if [ -n "$XT_WITH_ASSERTION_VERIFICATION_KEYS" ]; then args+=(--with-assertion-verification-keys "$XT_WITH_ASSERTION_VERIFICATION_KEYS") fi - if [ "$XT_WITH_ECWRAP" == 'true' ]; then - args+=(--session-key-algorithm "ec:secp256r1") + if [[ -n "$XT_WITH_SESSION_KEY_ALGORITHM" ]]; then + args+=(--session-key-algorithm "$XT_WITH_SESSION_KEY_ALGORITHM") fi if [ "$XT_WITH_VERIFY_ASSERTIONS" == 'false' ]; then args+=(--no-verify-assertions) diff --git a/xtest/sdk/java/cli.sh b/xtest/sdk/java/cli.sh index 79cb17d8..f4b30727 100755 --- a/xtest/sdk/java/cli.sh +++ b/xtest/sdk/java/cli.sh @@ -11,13 +11,14 @@ # # Extended Configuration: # XT_WITH_ECDSA_BINDING [boolean] - Use ECDSA binding for encryption -# XT_WITH_ECWRAP [boolean] - Use EC wrap for encryption/decryption +# XT_WITH_ECWRAP [boolean] - Use EC wrap for the TDF's own KAO wrapping key on encryption (use XT_WITH_SESSION_KEY_ALGORITHM for decryption) # XT_WITH_VERIFY_ASSERTIONS [boolean] - Verify assertions during decryption # XT_WITH_ASSERTIONS [string] - Path to assertions file, or JSON encoded as string # XT_WITH_ASSERTION_VERIFICATION_KEYS [string] - Path to assertion verification private key file # XT_WITH_ATTRIBUTES [string] - Attributes to be used for encryption # XT_WITH_MIME_TYPE [string] - MIME type for the encrypted file # XT_WITH_TARGET_MODE [string] - Target spec mode for the encrypted file +# XT_WITH_SESSION_KEY_ALGORITHM [string] - Rewrap session key algorithm for decryption (e.g. mlkem:768) # SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd) @@ -125,6 +126,16 @@ if [ "$1" == "supports" ]; then java -jar "$SCRIPT_DIR"/cmdline.jar help encrypt | grep -i "mlkem:768" exit $? ;; + session-key-mlkem) + # --rewrap-key-type has long accepted "mlkem:768" as a value (its choices + # come from the same KeyType enum used for KAS-managed-key mechanisms), + # so grepping --help for it would false-positive on builds that predate + # KASClient actually decapsulating an ML-KEM rewrap response. Use the + # explicit `supports` subcommand instead, which is a hardcoded, + # source-controlled feature list scoped to this exact capability. + java -jar "$SCRIPT_DIR"/cmdline.jar supports session-key-mlkem + exit $? + ;; mechanism-rsa-4096 | mechanism-ec-curves-384-521) # rsa4096 support in >= 0.13.0 set -o pipefail @@ -186,8 +197,8 @@ if [ "$1" == "encrypt" ]; then args+=(--policy-type="plaintext") fi else - if [ "$XT_WITH_ECWRAP" == 'true' ]; then - args+=(--rewrap-key-type="ec:secp256r1") + if [[ -n "$XT_WITH_SESSION_KEY_ALGORITHM" ]]; then + args+=(--rewrap-key-type="$XT_WITH_SESSION_KEY_ALGORITHM") fi fi diff --git a/xtest/sdk/js/cli.sh b/xtest/sdk/js/cli.sh index fe12a959..7ce2a5b7 100755 --- a/xtest/sdk/js/cli.sh +++ b/xtest/sdk/js/cli.sh @@ -11,7 +11,7 @@ # # Extended Configuration: # XT_WITH_ECDSA_BINDING [boolean] - Use ECDSA binding for encryption -# XT_WITH_ECWRAP [boolean] - Use EC wrap for encryption/decryption +# XT_WITH_ECWRAP [boolean] - Use EC wrap for the TDF's own KAO wrapping key on encryption (use XT_WITH_SESSION_KEY_ALGORITHM for decryption) # XT_WITH_VERIFY_ASSERTIONS [boolean] - Verify assertions during decryption # XT_WITH_ASSERTIONS [string] - Path to assertions file, or JSON encoded as string # XT_WITH_ASSERTION_VERIFICATION_KEYS [string] - Path to assertion verification private key file @@ -20,6 +20,7 @@ # XT_WITH_TARGET_MODE [string] - Target spec mode for the encrypted file # XT_WITH_DPOP [string] - Enable DPoP token binding; value selects algorithm (e.g. ES256) # XT_WITH_DPOP_KEY [string] - Path to PEM-encoded PKCS8 private key for DPoP signing +# XT_WITH_SESSION_KEY_ALGORITHM [string] - Rewrap session key algorithm for decryption (e.g. mlkem:768) # CLIENTID [string] - Override OIDC client ID (default: opentdf) # CLIENTSECRET [string] - Override OIDC client secret (default: secret) # @@ -100,6 +101,17 @@ if [[ "$1" == "supports" ]]; then npx $CTL encrypt --help | grep -i 'mlkem:768' exit $? ;; + session-key-mlkem) + # --rewrapKeyType has long accepted "mlkem:768" as a choice (it shares + # PUBLIC_KEY_ALGORITHMS with --encapKeyType, used for KAS-managed-key + # mechanisms), so grepping --help for it would false-positive on builds + # that predate decryptStreamFrom() actually forwarding the requested + # algorithm to unwrapKey(). Use the explicit supportedFeatures list from + # --version instead, which is hardcoded and scoped to this capability. + set -o pipefail + npx $CTL --version | jq -e '.supportedFeatures | index("session-key-mlkem")' >/dev/null + exit $? + ;; mechanism-xwing) set -o pipefail npx $CTL help | grep -i xwing @@ -252,8 +264,8 @@ elif [[ "$1" == "decrypt" ]]; then if [[ "$XT_WITH_VERIFY_ASSERTIONS" == 'false' ]]; then args+=(--noVerifyAssertions) fi - if [[ "$XT_WITH_ECWRAP" == 'true' ]]; then - args+=(--rewrapKeyType "ec:secp256r1") + if [[ -n "$XT_WITH_SESSION_KEY_ALGORITHM" ]]; then + args+=(--rewrapKeyType "$XT_WITH_SESSION_KEY_ALGORITHM") fi if [[ -n "$XT_WITH_KAS_ALLOW_LIST" ]]; then args+=(--allowList "$XT_WITH_KAS_ALLOW_LIST") diff --git a/xtest/tdfs.py b/xtest/tdfs.py index d09ca470..1d467e60 100644 --- a/xtest/tdfs.py +++ b/xtest/tdfs.py @@ -149,6 +149,11 @@ def is_sdk_type(val: str) -> TypeIs[sdk_type]: "mechanism-secpmlkem", # Support for pure (non-hybrid) ML-KEM key wrapping: mlkem:768 and mlkem:1024. "mechanism-mlkem", + # Support for a client-generated ML-KEM key pair as the rewrap "session key" + # (the ephemeral key used to wrap the KAS's rewrap response back to the + # client), as opposed to "mechanism-mlkem" which covers ML-KEM as the + # KAS-managed TDF/KAO wrapping key. + "session-key-mlkem", "ns_grants", "obligations", ] @@ -244,6 +249,21 @@ def __init__(self, **kwargs: dict[str, Any]): self.features.add("mechanism-secpmlkem") if any(a.startswith("mlkem:") for a in algs): self.features.add("mechanism-mlkem") + # The rewrap session-key ML-KEM path is gated server-side by the + # same Preview.MLKEMTDFEnabled flag as the KAS-managed mechanism, + # so a platform with ML-KEM keyring algs also accepts an ML-KEM + # client session key. + # + # Known imprecision: Preview.MLKEMTDFEnabled predates the + # rewrap-session-key code path (DSPX-4221), so this reports + # "supported" for any platform build with the mechanism enabled, + # even one that predates the session-key fix and would reject an + # ML-KEM clientPublicKey outright. Harmless today because every + # SDK's own "session-key-mlkem" probe (a hardcoded feature list, + # not a live capability check) independently gates on its own + # fix landing; revisit if a platform-only version skew scenario + # is ever needed here too. + self.features.add("session-key-mlkem") # DPoP capabilities via well-known. Branch builds report stale semver # so we probe the live endpoint instead of gating by version. @@ -561,10 +581,10 @@ def decrypt( container: container_type = "ztdf", assert_keys: str = "", verify_assertions: bool = True, - ecwrap: bool = False, expect_error: bool = False, kasallowlist: str = "", ignore_kas_allowlist: bool = False, + session_key_algorithm: str = "", ): fmt = simple_container(container) @@ -579,8 +599,8 @@ def decrypt( local_env: dict[str, str] = {} if assert_keys: local_env |= {"XT_WITH_ASSERTION_VERIFICATION_KEYS": assert_keys} - if ecwrap: - local_env |= {"XT_WITH_ECWRAP": "true"} + if session_key_algorithm: + local_env |= {"XT_WITH_SESSION_KEY_ALGORITHM": session_key_algorithm} if not verify_assertions: local_env |= {"XT_WITH_VERIFY_ASSERTIONS": "false"} if kasallowlist: diff --git a/xtest/test_pqc.py b/xtest/test_pqc.py index 9ad25998..eedc1312 100644 --- a/xtest/test_pqc.py +++ b/xtest/test_pqc.py @@ -12,9 +12,15 @@ import tdfs from abac import Attribute, KasKey +from audit_logs import AuditLogAsserter from fixtures.encryption import EncryptFactory from tdfs import KeyAccessObject +# Pure ML-KEM session-key algorithm identifiers, as passed to +# SDK.decrypt(session_key_algorithm=...) / the CLI wrappers. +SESSION_KEY_MLKEM_768 = "mlkem:768" +SESSION_KEY_MLKEM_1024 = "mlkem:1024" + # X-Wing KEM sizes per draft-connolly-cfrg-xwing-kem-10 XWING_ENCAPSULATION_KEY_SIZE = 1216 # public key, bytes XWING_CIPHERTEXT_SIZE = 1120 # KEM ciphertext (wrappedKey), bytes @@ -371,6 +377,101 @@ def test_mlkem_768_roundtrip( assert filecmp.cmp(pt_file, rt_file) +@pytest.mark.parametrize( + "session_key_algorithm", [SESSION_KEY_MLKEM_768, SESSION_KEY_MLKEM_1024] +) +def test_session_key_mlkem_roundtrip( + session_key_algorithm: str, + attribute_default_rsa: Attribute, + encrypt_sdk: tdfs.SDK, + decrypt_sdk: tdfs.SDK, + pt_file: Path, + in_focus: set[tdfs.SDK], + encrypted_tdf: EncryptFactory, + audit_logs: AuditLogAsserter, +): + """Rewrap with a client-generated ML-KEM ephemeral "session key". + + This exercises the rewrap response transport (the KAS wraps the DEK back + to a client-supplied ephemeral public key), which is independent of the + TDF's own KAO wrapping mechanism -- here a plain RSA-wrapped attribute key + is used so a failure can only be attributed to the session-key channel, + not to KAS-managed PQC mechanism support (see mechanism-mlkem tests above). + + A successful decrypt alone doesn't prove KAS actually used an ML-KEM + session key -- decrypt would still succeed if the SDK silently fell back + to its default (RSA) and KAS just answered in kind. So this asserts on + KAS's rewrap audit log, which records the parsed clientPublicKey's type + (eventMetaData.sessionKeyType) independently of anything the client + reports about itself. + """ + if not in_focus & {encrypt_sdk, decrypt_sdk}: + pytest.skip(f"Not in focus: encrypt={encrypt_sdk}, decrypt={decrypt_sdk}") + pfs = tdfs.get_platform_features() + pfs.skip_if_unsupported("session-key-mlkem") + decrypt_sdk.skip_if_unsupported("session-key-mlkem") + tdfs.skip_connectrpc_skew(encrypt_sdk, decrypt_sdk, pfs) + tdfs.skip_hexless_skew(encrypt_sdk, decrypt_sdk) + + ct_file = encrypted_tdf( + encrypt_sdk, + attr_values=attribute_default_rsa.value_fqns, + target_mode=tdfs.select_target_version(encrypt_sdk, decrypt_sdk), + ) + + manifest = tdfs.manifest(ct_file) + assert len(manifest.encryptionInformation.keyAccess) == 1 + assert manifest.encryptionInformation.keyAccess[0].type == "wrapped" + + mark = audit_logs.mark("before_decrypt") + rt_file = encrypted_tdf.rt_file(ct_file, decrypt_sdk, variant=session_key_algorithm) + decrypt_sdk.decrypt( + ct_file, rt_file, "ztdf", session_key_algorithm=session_key_algorithm + ) + assert filecmp.cmp(pt_file, rt_file, shallow=False) + + audit_logs.assert_rewrap_session_key_type(session_key_algorithm, since_mark=mark) + + +def test_session_key_rsa_roundtrip( + attribute_default_rsa: Attribute, + encrypt_sdk: tdfs.SDK, + decrypt_sdk: tdfs.SDK, + pt_file: Path, + in_focus: set[tdfs.SDK], + encrypted_tdf: EncryptFactory, + audit_logs: AuditLogAsserter, +): + """Rewrap with an explicitly-requested RSA session key. + + RSA is every SDK's default when no session-key algorithm is requested at + all, so ordinary roundtrip tests never pin an explicit session_key_type in + their audit assertions -- doing so would make them brittle to a future + default change. This test sidesteps that by requesting "rsa:2048" + explicitly (same as test_session_key_mlkem_roundtrip does for ML-KEM), + confirming the audit log's sessionKeyType field is correct for the + classical algorithm too, not just the PQC ones. + """ + if not in_focus & {encrypt_sdk, decrypt_sdk}: + pytest.skip(f"Not in focus: encrypt={encrypt_sdk}, decrypt={decrypt_sdk}") + pfs = tdfs.get_platform_features() + tdfs.skip_connectrpc_skew(encrypt_sdk, decrypt_sdk, pfs) + tdfs.skip_hexless_skew(encrypt_sdk, decrypt_sdk) + + ct_file = encrypted_tdf( + encrypt_sdk, + attr_values=attribute_default_rsa.value_fqns, + target_mode=tdfs.select_target_version(encrypt_sdk, decrypt_sdk), + ) + + mark = audit_logs.mark("before_decrypt") + rt_file = encrypted_tdf.rt_file(ct_file, decrypt_sdk, variant="rsa:2048") + decrypt_sdk.decrypt(ct_file, rt_file, "ztdf", session_key_algorithm="rsa:2048") + assert filecmp.cmp(pt_file, rt_file, shallow=False) + + audit_logs.assert_rewrap_session_key_type("rsa:2048", since_mark=mark) + + def test_mlkem_1024_roundtrip( attribute_with_mlkem_1024_key: tuple[Attribute, list[str]], key_mlkem_1024: KasKey, diff --git a/xtest/test_tdfs.py b/xtest/test_tdfs.py index 1b528ab5..35edfaf7 100644 --- a/xtest/test_tdfs.py +++ b/xtest/test_tdfs.py @@ -102,10 +102,15 @@ def test_tdf_roundtrip( ): ert_file = encrypted_tdf.rt_file(ct_file, decrypt_sdk, variant="ecrewrap") ec_mark = audit_logs.mark("before_ecwrap_decrypt") - decrypt_sdk.decrypt(ct_file, ert_file, container, ecwrap=True) + decrypt_sdk.decrypt( + ct_file, ert_file, container, session_key_algorithm="ec:secp256r1" + ) assert filecmp.cmp(pt_file, ert_file) - # Verify ecwrap rewrap was also logged - audit_logs.assert_rewrap_success(min_count=1, since_mark=ec_mark) + # Verify ecwrap rewrap was also logged, with the negotiated session-key + # type recorded correctly. Safe to pin here (unlike the plain decrypt + # above) since session_key_algorithm explicitly requests EC rather than + # relying on whatever an SDK's default happens to be. + audit_logs.assert_rewrap_session_key_type("ec:secp256r1", since_mark=ec_mark) def test_tdf_spec_target_422(