From 8a5790ea9634160fe1b70f83e29bd9f5b13f7bac Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Fri, 31 Jul 2026 16:50:48 -0400 Subject: [PATCH 1/2] spec: scaffold for DSPX-4221 pq-sessions --- spec/DSPX-4221.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 spec/DSPX-4221.md diff --git a/spec/DSPX-4221.md b/spec/DSPX-4221.md new file mode 100644 index 00000000..e4fd78e7 --- /dev/null +++ b/spec/DSPX-4221.md @@ -0,0 +1,38 @@ +--- +ticket: DSPX-4221 +title: Session Keys should support ML-KEM +status: draft +authors: + - dmihalcik@virtru.com +branches: + - opentdf/tests:DSPX-4221-pq-sessions +prs: [] +created: 2026-07-31T00:00:00Z +updated: 2026-07-31T00: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 +_Why does this work need to happen? What is the user/business pain?_ + +## Proposed Solution +_What will you build, at a functional level? Sketch the approach._ + +## Inputs / Outputs / Contracts +_Function signatures, data shapes, API contracts, CLI flags._ + +## Edge Cases & Constraints +_Boundary conditions, error states, performance limits, security considerations._ + +## Out of Scope +_What this work item explicitly does not cover._ + +## Acceptance Criteria +- [ ] _Clear, testable condition_ +- [ ] _…_ From c7bfda5926ec60ca0145716637336f1687bf940b Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Fri, 31 Jul 2026 17:13:22 -0400 Subject: [PATCH 2/2] feat(xtest): add ML-KEM rewrap session-key roundtrip test Address CodeRabbit review comments on spec/DSPX-4221.md: - Note the platform-side session-key-mlkem feature-detection imprecision (reuses the pre-existing Preview.MLKEMTDFEnabled/mechanism probe rather than a dedicated readiness check; each SDK's own hardcoded capability flag is what actually gates the test), matching the comment already in tdfs.py's feature-detection block. - Fix invalid Go syntax in the SDK contract example (ocrypto.KeyType is a string type; bitwise-OR between two enum values doesn't compile). - Remove the "and vice versa" independence claim, which overstated test coverage (only RSA-wrapped-TDF + ML-KEM-session-key is tested). Fix a real CI failure this surfaced: the plain assert_rewrap_success( session_key_type=X) pattern used for the new EC/RSA/ML-KEM session-key-type assertions has two problems that only show up once you assert on the *specific* algorithm rather than just "a rewrap succeeded": 1. eventMetaData.sessionKeyType is new (added by the companion platform PR, unreleased) and not behind any version/preview flag we can check statically. Asserting a specific value against a baseline/pre-fix platform build finds zero matches -- indistinguishable from a real negotiation bug. Broke test_tdf_roundtrip's new ecwrap assertion and test_session_key_rsa_roundtrip against CI's baseline (unpatched) platform matrix. 2. Log collection is poll-based. When two rewraps for different session keys happen back-to-back in the same test (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-based assertion then reports success using the wrong (earlier) event instead of waiting for the right one. Reproduced locally: test_tdf_roundtrip[...-ztdf-ecwrap] failed 3/3 runs with "Expected session_key_type=ec:secp256r1, but platform reported {'rsa:2048'}" even against the patched platform PR branch, where the feature itself works correctly (confirmed via a standalone otdfctl decrypt + raw platform.log inspection). Added AuditLogAsserter.assert_rewrap_session_key_type(), which waits for an event matching the expected type before falling back to a short existence check (present-but-different-value still fails loudly; field-never-present logs a warning and passes). Used by test_tdf_roundtrip's ecwrap block and both test_session_key_rsa_roundtrip and test_session_key_mlkem_roundtrip. Verified locally against the platform PR branch: all three pass consistently now (previously ecwrap failed 3/3 runs). Also address a Copilot review finding on SDK.decrypt(): ecwrap=True and session_key_algorithm could both be set, which the CLI wrappers turn into duplicate/competing --session-key-algorithm (or --rewrap-key-type) flags, with the winner depending on the underlying CLI's parsing order rather than caller intent. No current call site combines them, but rather than leave that as an implicit landmine, decrypt() now raises ValueError if both are passed -- ecwrap is shorthand for session_key_algorithm="ec:secp256r1" anyway. Remove the now-redundant decrypt-side XT_WITH_ECWRAP shortcut. It predates session_key_algorithm and was pure sugar for session_key_algorithm="ec:secp256r1"; XT_WITH_ECWRAP stays for encrypt (selects the TDF's own KAO wrapping mechanism, an unrelated axis). Dropped SDK.decrypt()'s ecwrap bool param (one call site, migrated to session_key_algorithm), removed the dead decrypt-branch XT_WITH_ECWRAP handling from all three cli.sh wrappers, and updated each script's env-var doc comment. Verified against go, java, and js (companion PR branch) decrypt SDKs -- all pass. Add a TODO in assert_rewrap_session_key_type() documenting the plan for closing the remaining gap: once opentdf/platform#3814 merges and cuts a release, gate on a version-checked "audit-session-key-type" feature (mirroring the existing audit_logging pattern) and hard-fail on mismatch instead of warning. Not actionable yet since there is no release version to check against pre-merge -- see the stacked draft PR reproducing the gap this leaves open (unpatched web-sdk silently sending the wrong session-key algorithm on EC rewrap, masked when paired with an unpatched platform that does not emit the field at all). Signed-off-by: Dave Mihalcik --- spec/DSPX-4221.md | 52 +++++++++++++++++----- xtest/audit_logs.py | 101 +++++++++++++++++++++++++++++++++++++++++- xtest/sdk/go/cli.sh | 12 +++-- xtest/sdk/java/cli.sh | 17 +++++-- xtest/sdk/js/cli.sh | 18 ++++++-- xtest/tdfs.py | 26 +++++++++-- xtest/test_pqc.py | 101 ++++++++++++++++++++++++++++++++++++++++++ xtest/test_tdfs.py | 11 +++-- 8 files changed, 310 insertions(+), 28 deletions(-) diff --git a/spec/DSPX-4221.md b/spec/DSPX-4221.md index e4fd78e7..329055f2 100644 --- a/spec/DSPX-4221.md +++ b/spec/DSPX-4221.md @@ -1,38 +1,66 @@ --- ticket: DSPX-4221 title: Session Keys should support ML-KEM -status: draft +status: in-review authors: - dmihalcik@virtru.com branches: - opentdf/tests:DSPX-4221-pq-sessions -prs: [] + - 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-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) + +Make sure all clients (and the server) support ML-KEM as the session encryption key (client-generated key pair). ## Problem / Motivation -_Why does this work need to happen? What is the user/business pain?_ + +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 -_What will you build, at a functional level? Sketch the approach._ + +- **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 -_Function signatures, data shapes, API contracts, CLI flags._ + +- 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 -_Boundary conditions, error states, performance limits, security considerations._ + +- 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 -_What this work item explicitly does not cover._ + +- 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 -- [ ] _Clear, testable condition_ -- [ ] _…_ + +- [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(