From 41482fc3f7b9e0ac3b4f8cbf3dcec0e17c940c20 Mon Sep 17 00:00:00 2001 From: Krish Suchak Date: Tue, 30 Jun 2026 18:16:05 -0400 Subject: [PATCH 1/2] test: add cross-SDK key-mapping split parity scaffold Add a key_mapping_resolution platform feature gate and a parity test asserting a go SDK that resolves key splits via GetKeyMappingsByFqns produces the same TDF key-access split structure as a previous released go SDK, with cross-decrypt. Covers value-level mapped keys; definition/namespace levels and any_of/hierarchy across distinct KASes are TODO (need key_assign_attribute/key_assign_namespace otdfctl helpers). Skips until the platform exposes the RPC and a new go SDK build is present alongside a released one. Signed-off-by: Krish Suchak --- xtest/tdfs.py | 9 ++++ xtest/test_abac_key_mapping_parity.py | 78 +++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 xtest/test_abac_key_mapping_parity.py diff --git a/xtest/tdfs.py b/xtest/tdfs.py index d09ca470..f7740714 100644 --- a/xtest/tdfs.py +++ b/xtest/tdfs.py @@ -139,6 +139,9 @@ def is_sdk_type(val: str) -> TypeIs[sdk_type]: # including splitting with multiple keys on the same kas (sdk feature), # and explicit management of the KAS keys through the policy service (otdfctl+service feature). "key_management", + # Platform exposes the narrow GetKeyMappingsByFqns RPC (server-side + # value > definition > namespace key resolution for client key splits). + "key_mapping_resolution", # Support for encrypting with RSA-4096 managed keys. "mechanism-rsa-4096", # Support for encrypting with EC curves secp384r1 and secp521r1 managed keys. @@ -219,6 +222,12 @@ def __init__(self, **kwargs: dict[str, Any]): # Included in platform v0.12.0 if self.semver >= (0, 12, 0): self.features.add("attribute_traversal") + + # GetKeyMappingsByFqns RPC. TODO: pin to the actual release version once + # the proto change (opentdf/platform#3634) is released; gated + # conservatively until then. + if self.semver >= (0, 14, 0): + self.features.add("key_mapping_resolution") # In ocrypto < 0.10.0, there was a bug that hardcoded to P256 on uncompressing the EC public key, # even if the key was actually P384 or P521. This was fixed in ocrypto 0.10.0, so we can only support EC # wrapping with those curves on platforms v0.13.0 and later. diff --git a/xtest/test_abac_key_mapping_parity.py b/xtest/test_abac_key_mapping_parity.py new file mode 100644 index 00000000..047b6770 --- /dev/null +++ b/xtest/test_abac_key_mapping_parity.py @@ -0,0 +1,78 @@ +"""Cross-SDK parity for GetKeyMappingsByFqns-based key split resolution. + +Verifies that a go SDK build which resolves key splits via the new +GetKeyMappingsByFqns RPC produces the same TDF key-access split structure as a +previous released go SDK (which walks the full attribute set), and that the two +containers cross-decrypt. + +Status: initial scaffold. It can only pass once both of the following hold, so it +skips otherwise: + - the platform under test exposes GetKeyMappingsByFqns (the + ``key_mapping_resolution`` feature, pinned to the release that ships + opentdf/platform#3634), and + - a "new" go SDK build that uses the new granter (opentdf/platform#3699) is + present in ``sdk/go/dist`` alongside a previous released go SDK. + +Coverage is currently value-level mapped keys, since otdfctl.py only exposes +``key_assign_value``. Definition- and namespace-level mapped-key cases, and +any_of / hierarchy rules with keys on distinct KASes, are TODO and need +``key_assign_attribute`` / ``key_assign_namespace`` helpers plus multi-KAS keyed +fixtures. +""" + +import filecmp +from pathlib import Path + +import pytest + +import tdfs +from abac import Attribute + + +def _split_structure(m: tdfs.Manifest) -> set[frozenset[tuple[str, str | None]]]: + """Map each split (grouped by split id) to its set of (kas_url, kid) pairs. + + Split ids are random per encrypt, so the collection of (url, kid) sets is the + stable, comparable representation of the split plan. + """ + by_sid: dict[str, set[tuple[str, str | None]]] = {} + for kao in m.encryptionInformation.keyAccess: + by_sid.setdefault(kao.sid or "", set()).add((kao.url, kao.kid)) + return {frozenset(pairs) for pairs in by_sid.values()} + + +def _go_sdk_pair() -> tuple[tdfs.SDK, tdfs.SDK]: + go = tdfs.all_versions_of("go") + new = next((s for s in go if not s.is_released()), None) + prev = next((s for s in go if s.is_released()), None) + if new is None or prev is None: + pytest.skip("requires both a new (main/sha) and a previous released go SDK build") + return new, prev + + +def test_key_mapping_split_parity_value_level( + attribute_with_different_kids: Attribute, + pt_file: Path, + tmp_dir: Path, +): + pfs = tdfs.get_platform_features() + pfs.skip_if_unsupported("key_management", "key_mapping_resolution") + new_sdk, prev_sdk = _go_sdk_pair() + + fqns = attribute_with_different_kids.value_fqns + + new_ct = tmp_dir / "parity-new.tdf" + prev_ct = tmp_dir / "parity-prev.tdf" + new_sdk.encrypt(pt_file, new_ct, container="ztdf", attr_values=fqns) + prev_sdk.encrypt(pt_file, prev_ct, container="ztdf", attr_values=fqns) + + # Server-resolved splits (new SDK) must match client-resolved splits (previous SDK). + assert _split_structure(tdfs.manifest(new_ct)) == _split_structure( + tdfs.manifest(prev_ct) + ) + + # The two containers must also cross-decrypt. + for ct, decrypt_sdk in ((new_ct, prev_sdk), (prev_ct, new_sdk)): + rt = tmp_dir / f"{ct.stem}.untdf" + decrypt_sdk.decrypt(ct, rt, "ztdf") + assert filecmp.cmp(pt_file, rt) From 795819c6e82fd2e551f352e25d4241848ff905e8 Mon Sep 17 00:00:00 2001 From: Krish Suchak Date: Wed, 1 Jul 2026 19:59:11 -0400 Subject: [PATCH 2/2] test: broaden key-mapping split parity to multi-KAS any_of/all_of The server now resolves legacy grants inside GetKeyMappingsByFqns (grants with a cached key are converted to keys), so the parity story covers grant-configured policy too. Broaden the parity test from a single-KAS multi-KID case to also cover two-KAS ANY_OF (share) and multi-value ALL_OF (split), which exercise the rule-driven combine logic through the new resolution path. Document that legacy grants auto-convert to mapped keys on key_management platforms, so a dedicated legacy-grant-only case remains a TODO. Signed-off-by: Krish Suchak --- xtest/test_abac_key_mapping_parity.py | 76 +++++++++++++++++++-------- 1 file changed, 53 insertions(+), 23 deletions(-) diff --git a/xtest/test_abac_key_mapping_parity.py b/xtest/test_abac_key_mapping_parity.py index 047b6770..87ce9716 100644 --- a/xtest/test_abac_key_mapping_parity.py +++ b/xtest/test_abac_key_mapping_parity.py @@ -2,22 +2,28 @@ Verifies that a go SDK build which resolves key splits via the new GetKeyMappingsByFqns RPC produces the same TDF key-access split structure as a -previous released go SDK (which walks the full attribute set), and that the two -containers cross-decrypt. - -Status: initial scaffold. It can only pass once both of the following hold, so it -skips otherwise: - - the platform under test exposes GetKeyMappingsByFqns (the - ``key_mapping_resolution`` feature, pinned to the release that ships - opentdf/platform#3634), and - - a "new" go SDK build that uses the new granter (opentdf/platform#3699) is - present in ``sdk/go/dist`` alongside a previous released go SDK. - -Coverage is currently value-level mapped keys, since otdfctl.py only exposes -``key_assign_value``. Definition- and namespace-level mapped-key cases, and -any_of / hierarchy rules with keys on distinct KASes, are TODO and need -``key_assign_attribute`` / ``key_assign_namespace`` helpers plus multi-KAS keyed -fixtures. +previous released go SDK (which walks the full attribute set via +GetAttributeValuesByFqns), and that the two containers cross-decrypt. + +The server now resolves both mapped keys and legacy KAS grants inside +GetKeyMappingsByFqns (value > definition > namespace; grants with a cached public +key are converted to keys). The go SDK falls back to GetAttributeValuesByFqns +only for values the server returns no keys for (remote/uncached-key grants), so +split output should match the previous SDK for every configuration below. + +Status: scaffold. It skips unless both hold: + - the platform exposes GetKeyMappingsByFqns (``key_mapping_resolution`` feature, + pinned to the release that ships opentdf/platform#3634), and + - a "new" go SDK build using the new granter (opentdf/platform#3699) is present + in ``sdk/go/dist`` alongside a previous released go SDK. + +Coverage: value-level keys across single-KAS multi-KID, two-KAS ANY_OF (share), +and multi-value ALL_OF (split) configurations. NOTE: on platforms with +``key_management`` the two_kas_grant fixtures assign *mapped keys* (grants +auto-convert), so this suite exercises the mapped-key path plus grant→key +conversion for cached-key KAS. A dedicated *legacy-grant-only* parity case (grants +that never convert) is a TODO: it needs a fixture that forces ``grant_assign_value`` +even when ``key_management`` is supported. """ import filecmp @@ -50,17 +56,14 @@ def _go_sdk_pair() -> tuple[tdfs.SDK, tdfs.SDK]: return new, prev -def test_key_mapping_split_parity_value_level( - attribute_with_different_kids: Attribute, - pt_file: Path, - tmp_dir: Path, -): +def _assert_split_parity(attr: Attribute, pt_file: Path, tmp_dir: Path) -> None: + """Encrypt the same plaintext with the new and previous go SDK over the + attribute's value FQNs, assert identical split structure, and cross-decrypt.""" pfs = tdfs.get_platform_features() pfs.skip_if_unsupported("key_management", "key_mapping_resolution") new_sdk, prev_sdk = _go_sdk_pair() - fqns = attribute_with_different_kids.value_fqns - + fqns = attr.value_fqns new_ct = tmp_dir / "parity-new.tdf" prev_ct = tmp_dir / "parity-prev.tdf" new_sdk.encrypt(pt_file, new_ct, container="ztdf", attr_values=fqns) @@ -76,3 +79,30 @@ def test_key_mapping_split_parity_value_level( rt = tmp_dir / f"{ct.stem}.untdf" decrypt_sdk.decrypt(ct, rt, "ztdf") assert filecmp.cmp(pt_file, rt) + + +def test_key_mapping_split_parity_single_kas_multikid( + attribute_with_different_kids: Attribute, + pt_file: Path, + tmp_dir: Path, +): + # Single KAS, two value-level keys with different KIDs. + _assert_split_parity(attribute_with_different_kids, pt_file, tmp_dir) + + +def test_key_mapping_split_parity_two_kas_any_of( + attribute_two_kas_grant_or: Attribute, + pt_file: Path, + tmp_dir: Path, +): + # ANY_OF across two distinct KAS (a share). Exercises rule-driven combine. + _assert_split_parity(attribute_two_kas_grant_or, pt_file, tmp_dir) + + +def test_key_mapping_split_parity_all_of( + attribute_two_kas_grant_and: Attribute, + pt_file: Path, + tmp_dir: Path, +): + # ALL_OF across multiple values/KAS (a split). Exercises rule-driven combine. + _assert_split_parity(attribute_two_kas_grant_and, pt_file, tmp_dir)