Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions xtest/tdfs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
108 changes: 108 additions & 0 deletions xtest/test_abac_key_mapping_parity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""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 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
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
Comment on lines +50 to +56

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In _go_sdk_pair(), next((s for s in go if s.is_released()), None) is used to retrieve a released version of the Go SDK. Since os.listdir (used by tdfs.all_versions_of) returns directory entries in an arbitrary order, this will non-deterministically select any released version present in the directory. To ensure deterministic and correct test behavior, the released versions should be sorted (e.g., by semantic version) to consistently select the latest released version.

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)
    released = [s for s in go if s.is_released()]
    prev = None
    if released:
        released.sort(key=lambda s: tdfs._parse_semver(s.version) or (0, 0, 0), reverse=True)
        prev = released[0]
    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 _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 = 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)
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)
Comment on lines +78 to +81

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

By default, filecmp.cmp uses a shallow comparison (shallow=True), which only compares the os.stat signature (size, mtime, etc.) of the files. In fast-running test environments where files are created and modified rapidly, this can lead to false positives due to identical stat metadata. It is safer to use shallow=False to force a byte-by-byte content comparison.

Suggested change
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)
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, shallow=False)



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)
Loading