otdf-python 0.1.10__py3-none-any.whl → 0.3.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. otdf_python/__init__.py +25 -0
  2. otdf_python/__main__.py +12 -0
  3. otdf_python/address_normalizer.py +84 -0
  4. otdf_python/aesgcm.py +55 -0
  5. otdf_python/assertion_config.py +84 -0
  6. otdf_python/asym_crypto.py +198 -0
  7. otdf_python/auth_headers.py +33 -0
  8. otdf_python/autoconfigure_utils.py +113 -0
  9. otdf_python/cli.py +569 -0
  10. otdf_python/collection_store.py +41 -0
  11. otdf_python/collection_store_impl.py +22 -0
  12. otdf_python/config.py +69 -0
  13. otdf_python/connect_client.py +0 -0
  14. otdf_python/constants.py +1 -0
  15. otdf_python/crypto_utils.py +78 -0
  16. otdf_python/dpop.py +81 -0
  17. otdf_python/ecc_constants.py +176 -0
  18. otdf_python/ecc_mode.py +83 -0
  19. otdf_python/ecdh.py +317 -0
  20. otdf_python/eckeypair.py +75 -0
  21. otdf_python/header.py +181 -0
  22. otdf_python/invalid_zip_exception.py +8 -0
  23. otdf_python/kas_client.py +709 -0
  24. otdf_python/kas_connect_rpc_client.py +213 -0
  25. otdf_python/kas_info.py +25 -0
  26. otdf_python/kas_key_cache.py +52 -0
  27. otdf_python/key_type.py +31 -0
  28. otdf_python/key_type_constants.py +43 -0
  29. otdf_python/manifest.py +215 -0
  30. otdf_python/nanotdf.py +863 -0
  31. otdf_python/nanotdf_ecdsa_struct.py +132 -0
  32. otdf_python/nanotdf_type.py +43 -0
  33. otdf_python/policy_binding_serializer.py +39 -0
  34. otdf_python/policy_info.py +55 -0
  35. otdf_python/policy_object.py +22 -0
  36. otdf_python/policy_stub.py +2 -0
  37. otdf_python/resource_locator.py +172 -0
  38. otdf_python/sdk.py +436 -0
  39. otdf_python/sdk_builder.py +416 -0
  40. otdf_python/sdk_exceptions.py +16 -0
  41. otdf_python/symmetric_and_payload_config.py +30 -0
  42. otdf_python/tdf.py +480 -0
  43. otdf_python/tdf_reader.py +153 -0
  44. otdf_python/tdf_writer.py +23 -0
  45. otdf_python/token_source.py +34 -0
  46. otdf_python/version.py +57 -0
  47. otdf_python/zip_reader.py +47 -0
  48. otdf_python/zip_writer.py +70 -0
  49. otdf_python-0.3.5.dist-info/METADATA +153 -0
  50. otdf_python-0.3.5.dist-info/RECORD +137 -0
  51. {otdf_python-0.1.10.dist-info → otdf_python-0.3.5.dist-info}/WHEEL +1 -2
  52. {otdf_python-0.1.10.dist-info → otdf_python-0.3.5.dist-info/licenses}/LICENSE +1 -1
  53. otdf_python_proto/__init__.py +37 -0
  54. otdf_python_proto/authorization/__init__.py +1 -0
  55. otdf_python_proto/authorization/authorization_pb2.py +80 -0
  56. otdf_python_proto/authorization/authorization_pb2.pyi +161 -0
  57. otdf_python_proto/authorization/authorization_pb2_connect.py +191 -0
  58. otdf_python_proto/authorization/v2/authorization_pb2.py +105 -0
  59. otdf_python_proto/authorization/v2/authorization_pb2.pyi +134 -0
  60. otdf_python_proto/authorization/v2/authorization_pb2_connect.py +233 -0
  61. otdf_python_proto/common/__init__.py +1 -0
  62. otdf_python_proto/common/common_pb2.py +52 -0
  63. otdf_python_proto/common/common_pb2.pyi +61 -0
  64. otdf_python_proto/entity/__init__.py +1 -0
  65. otdf_python_proto/entity/entity_pb2.py +47 -0
  66. otdf_python_proto/entity/entity_pb2.pyi +50 -0
  67. otdf_python_proto/entityresolution/__init__.py +1 -0
  68. otdf_python_proto/entityresolution/entity_resolution_pb2.py +57 -0
  69. otdf_python_proto/entityresolution/entity_resolution_pb2.pyi +55 -0
  70. otdf_python_proto/entityresolution/entity_resolution_pb2_connect.py +149 -0
  71. otdf_python_proto/entityresolution/v2/entity_resolution_pb2.py +55 -0
  72. otdf_python_proto/entityresolution/v2/entity_resolution_pb2.pyi +55 -0
  73. otdf_python_proto/entityresolution/v2/entity_resolution_pb2_connect.py +149 -0
  74. otdf_python_proto/kas/__init__.py +9 -0
  75. otdf_python_proto/kas/kas_pb2.py +103 -0
  76. otdf_python_proto/kas/kas_pb2.pyi +170 -0
  77. otdf_python_proto/kas/kas_pb2_connect.py +192 -0
  78. otdf_python_proto/legacy_grpc/__init__.py +1 -0
  79. otdf_python_proto/legacy_grpc/authorization/authorization_pb2_grpc.py +163 -0
  80. otdf_python_proto/legacy_grpc/authorization/v2/authorization_pb2_grpc.py +206 -0
  81. otdf_python_proto/legacy_grpc/common/common_pb2_grpc.py +4 -0
  82. otdf_python_proto/legacy_grpc/entity/entity_pb2_grpc.py +4 -0
  83. otdf_python_proto/legacy_grpc/entityresolution/entity_resolution_pb2_grpc.py +122 -0
  84. otdf_python_proto/legacy_grpc/entityresolution/v2/entity_resolution_pb2_grpc.py +120 -0
  85. otdf_python_proto/legacy_grpc/kas/kas_pb2_grpc.py +172 -0
  86. otdf_python_proto/legacy_grpc/logger/audit/test_pb2_grpc.py +4 -0
  87. otdf_python_proto/legacy_grpc/policy/actions/actions_pb2_grpc.py +249 -0
  88. otdf_python_proto/legacy_grpc/policy/attributes/attributes_pb2_grpc.py +873 -0
  89. otdf_python_proto/legacy_grpc/policy/kasregistry/key_access_server_registry_pb2_grpc.py +602 -0
  90. otdf_python_proto/legacy_grpc/policy/keymanagement/key_management_pb2_grpc.py +251 -0
  91. otdf_python_proto/legacy_grpc/policy/namespaces/namespaces_pb2_grpc.py +427 -0
  92. otdf_python_proto/legacy_grpc/policy/objects_pb2_grpc.py +4 -0
  93. otdf_python_proto/legacy_grpc/policy/registeredresources/registered_resources_pb2_grpc.py +524 -0
  94. otdf_python_proto/legacy_grpc/policy/resourcemapping/resource_mapping_pb2_grpc.py +516 -0
  95. otdf_python_proto/legacy_grpc/policy/selectors_pb2_grpc.py +4 -0
  96. otdf_python_proto/legacy_grpc/policy/subjectmapping/subject_mapping_pb2_grpc.py +551 -0
  97. otdf_python_proto/legacy_grpc/policy/unsafe/unsafe_pb2_grpc.py +485 -0
  98. otdf_python_proto/legacy_grpc/wellknownconfiguration/wellknown_configuration_pb2_grpc.py +77 -0
  99. otdf_python_proto/logger/__init__.py +1 -0
  100. otdf_python_proto/logger/audit/test_pb2.py +43 -0
  101. otdf_python_proto/logger/audit/test_pb2.pyi +45 -0
  102. otdf_python_proto/policy/__init__.py +1 -0
  103. otdf_python_proto/policy/actions/actions_pb2.py +75 -0
  104. otdf_python_proto/policy/actions/actions_pb2.pyi +87 -0
  105. otdf_python_proto/policy/actions/actions_pb2_connect.py +275 -0
  106. otdf_python_proto/policy/attributes/attributes_pb2.py +234 -0
  107. otdf_python_proto/policy/attributes/attributes_pb2.pyi +328 -0
  108. otdf_python_proto/policy/attributes/attributes_pb2_connect.py +863 -0
  109. otdf_python_proto/policy/kasregistry/key_access_server_registry_pb2.py +266 -0
  110. otdf_python_proto/policy/kasregistry/key_access_server_registry_pb2.pyi +450 -0
  111. otdf_python_proto/policy/kasregistry/key_access_server_registry_pb2_connect.py +611 -0
  112. otdf_python_proto/policy/keymanagement/key_management_pb2.py +79 -0
  113. otdf_python_proto/policy/keymanagement/key_management_pb2.pyi +87 -0
  114. otdf_python_proto/policy/keymanagement/key_management_pb2_connect.py +275 -0
  115. otdf_python_proto/policy/namespaces/namespaces_pb2.py +117 -0
  116. otdf_python_proto/policy/namespaces/namespaces_pb2.pyi +147 -0
  117. otdf_python_proto/policy/namespaces/namespaces_pb2_connect.py +443 -0
  118. otdf_python_proto/policy/objects_pb2.py +150 -0
  119. otdf_python_proto/policy/objects_pb2.pyi +464 -0
  120. otdf_python_proto/policy/registeredresources/registered_resources_pb2.py +139 -0
  121. otdf_python_proto/policy/registeredresources/registered_resources_pb2.pyi +196 -0
  122. otdf_python_proto/policy/registeredresources/registered_resources_pb2_connect.py +527 -0
  123. otdf_python_proto/policy/resourcemapping/resource_mapping_pb2.py +139 -0
  124. otdf_python_proto/policy/resourcemapping/resource_mapping_pb2.pyi +194 -0
  125. otdf_python_proto/policy/resourcemapping/resource_mapping_pb2_connect.py +527 -0
  126. otdf_python_proto/policy/selectors_pb2.py +57 -0
  127. otdf_python_proto/policy/selectors_pb2.pyi +90 -0
  128. otdf_python_proto/policy/subjectmapping/subject_mapping_pb2.py +127 -0
  129. otdf_python_proto/policy/subjectmapping/subject_mapping_pb2.pyi +189 -0
  130. otdf_python_proto/policy/subjectmapping/subject_mapping_pb2_connect.py +569 -0
  131. otdf_python_proto/policy/unsafe/unsafe_pb2.py +113 -0
  132. otdf_python_proto/policy/unsafe/unsafe_pb2.pyi +145 -0
  133. otdf_python_proto/policy/unsafe/unsafe_pb2_connect.py +485 -0
  134. otdf_python_proto/wellknownconfiguration/__init__.py +1 -0
  135. otdf_python_proto/wellknownconfiguration/wellknown_configuration_pb2.py +51 -0
  136. otdf_python_proto/wellknownconfiguration/wellknown_configuration_pb2.pyi +32 -0
  137. otdf_python_proto/wellknownconfiguration/wellknown_configuration_pb2_connect.py +107 -0
  138. otdf_python/_gotdf_python.cpython-312-darwin.so +0 -0
  139. otdf_python/build.py +0 -190
  140. otdf_python/go.py +0 -1478
  141. otdf_python/gotdf_python.py +0 -383
  142. otdf_python-0.1.10.dist-info/METADATA +0 -149
  143. otdf_python-0.1.10.dist-info/RECORD +0 -10
  144. otdf_python-0.1.10.dist-info/top_level.txt +0 -1
otdf_python/nanotdf.py ADDED
@@ -0,0 +1,863 @@
1
+ import contextlib
2
+ import hashlib
3
+ import json
4
+ import secrets
5
+ from io import BytesIO
6
+ from typing import BinaryIO
7
+
8
+ from cryptography.hazmat.primitives import serialization
9
+ from cryptography.hazmat.primitives.asymmetric import ec
10
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
11
+
12
+ from otdf_python.collection_store import CollectionStore, NoOpCollectionStore
13
+ from otdf_python.config import KASInfo, NanoTDFConfig
14
+ from otdf_python.constants import MAGIC_NUMBER_AND_VERSION
15
+ from otdf_python.ecc_mode import ECCMode
16
+ from otdf_python.policy_info import PolicyInfo
17
+ from otdf_python.policy_object import AttributeObject, PolicyBody, PolicyObject
18
+ from otdf_python.policy_stub import NULL_POLICY_UUID
19
+ from otdf_python.resource_locator import ResourceLocator
20
+ from otdf_python.sdk_exceptions import SDKException
21
+ from otdf_python.symmetric_and_payload_config import SymmetricAndPayloadConfig
22
+
23
+ from .asym_crypto import AsymDecryption
24
+
25
+
26
+ class NanoTDFException(SDKException):
27
+ pass
28
+
29
+
30
+ class NanoTDFMaxSizeLimit(NanoTDFException):
31
+ pass
32
+
33
+
34
+ class UnsupportedNanoTDFFeature(NanoTDFException):
35
+ pass
36
+
37
+
38
+ class InvalidNanoTDFConfig(NanoTDFException):
39
+ pass
40
+
41
+
42
+ class NanoTDF:
43
+ MAGIC_NUMBER_AND_VERSION = MAGIC_NUMBER_AND_VERSION
44
+ K_MAX_TDF_SIZE = (16 * 1024 * 1024) - 3 - 32
45
+ K_NANOTDF_GMAC_LENGTH = 8
46
+ K_IV_PADDING = 9
47
+ K_NANOTDF_IV_SIZE = 3
48
+ K_EMPTY_IV = bytes([0x0] * 12)
49
+
50
+ def __init__(self, services=None, collection_store: CollectionStore | None = None):
51
+ self.services = services
52
+ self.collection_store = collection_store or NoOpCollectionStore()
53
+
54
+ def _create_policy_object(self, attributes: list[str]) -> PolicyObject:
55
+ # TODO: Replace this with a proper Policy UUID value
56
+ policy_uuid = NULL_POLICY_UUID
57
+ data_attributes = [AttributeObject(attribute=a) for a in attributes]
58
+ body = PolicyBody(data_attributes=data_attributes, dissem=[])
59
+ return PolicyObject(uuid=policy_uuid, body=body)
60
+
61
+ def _serialize_policy_object(self, obj):
62
+ """Custom NanoTDF serializer to convert to compatible JSON format."""
63
+ from otdf_python.policy_object import AttributeObject, PolicyBody
64
+
65
+ if isinstance(obj, PolicyBody):
66
+ # Convert data_attributes to dataAttributes and use null instead of empty array
67
+ result = {
68
+ "dataAttributes": obj.data_attributes if obj.data_attributes else None,
69
+ "dissem": obj.dissem if obj.dissem else None,
70
+ }
71
+ return result
72
+ elif isinstance(obj, AttributeObject):
73
+ # Convert snake_case field names to camelCase for JSON serialization
74
+ return {
75
+ "attribute": obj.attribute,
76
+ "displayName": obj.display_name,
77
+ "isDefault": obj.is_default,
78
+ "pubKey": obj.pub_key,
79
+ "kasUrl": obj.kas_url,
80
+ }
81
+ else:
82
+ return obj.__dict__
83
+
84
+ def _prepare_payload(self, payload: bytes | BytesIO) -> bytes:
85
+ """
86
+ Convert BytesIO to bytes and validate payload size.
87
+
88
+ Args:
89
+ payload: The payload data as bytes or BytesIO
90
+
91
+ Returns:
92
+ bytes: The payload as bytes
93
+
94
+ Raises:
95
+ NanoTDFMaxSizeLimit: If the payload exceeds the maximum size
96
+ """
97
+ if isinstance(payload, BytesIO):
98
+ payload = payload.getvalue()
99
+ if len(payload) > self.K_MAX_TDF_SIZE:
100
+ raise NanoTDFMaxSizeLimit("exceeds max size for nano tdf")
101
+ return payload
102
+
103
+ def _prepare_policy_data(self, config: NanoTDFConfig) -> tuple[bytes, str]:
104
+ """
105
+ Prepare policy data from configuration.
106
+
107
+ Args:
108
+ config: NanoTDFConfig configuration
109
+
110
+ Returns:
111
+ tuple: (policy_body, policy_type)
112
+ """
113
+ attributes = config.attributes if config.attributes else []
114
+ policy_object = self._create_policy_object(attributes)
115
+ policy_json = json.dumps(
116
+ policy_object, default=self._serialize_policy_object
117
+ ).encode("utf-8")
118
+ policy_type = (
119
+ config.policy_type if config.policy_type else "EMBEDDED_POLICY_PLAIN_TEXT"
120
+ )
121
+
122
+ if policy_type == "EMBEDDED_POLICY_PLAIN_TEXT":
123
+ policy_body = policy_json
124
+ else:
125
+ # Encrypt policy
126
+ policy_key = secrets.token_bytes(32)
127
+ aesgcm = AESGCM(policy_key)
128
+ iv = secrets.token_bytes(12)
129
+ policy_body = aesgcm.encrypt(iv, policy_json, None)
130
+
131
+ return policy_body, policy_type
132
+
133
+ def _prepare_encryption_key(self, config: NanoTDFConfig) -> bytes:
134
+ """Get encryption key from config if provided as hex string, otherwise generate a new random key."""
135
+ key = None
136
+ if (
137
+ config.cipher
138
+ and isinstance(config.cipher, str)
139
+ and all(c in "0123456789abcdefABCDEF" for c in config.cipher)
140
+ ):
141
+ key = bytes.fromhex(config.cipher)
142
+ if not key:
143
+ key = secrets.token_bytes(32)
144
+ return key
145
+
146
+ def _create_header(
147
+ self,
148
+ policy_body: bytes,
149
+ policy_type: str,
150
+ config: NanoTDFConfig,
151
+ ephemeral_public_key: bytes | None = None,
152
+ ) -> bytes:
153
+ """
154
+ Create the NanoTDF header.
155
+
156
+ Args:
157
+ policy_body: The policy body bytes
158
+ policy_type: The policy type string
159
+ config: NanoTDFConfig configuration
160
+ ephemeral_public_key: Optional compressed ephemeral public key (from ECDH)
161
+
162
+ Returns:
163
+ bytes: The header bytes
164
+ """
165
+ from otdf_python.header import Header # Local import to avoid circular import
166
+
167
+ # KAS URL from KASInfo or default
168
+ kas_url = "https://kas.example.com"
169
+ if config.kas_info_list and len(config.kas_info_list) > 0:
170
+ kas_url = config.kas_info_list[0].url
171
+
172
+ # KAS Key ID - use "e1" for EC (ECDH) mode or "r1" for RSA mode
173
+ # If ephemeral_public_key is provided, we're using ECDH (EC), otherwise RSA
174
+ # EC key ID, use "e1"
175
+ # RSA key ID, use "r1"
176
+ kas_id = "e1" if ephemeral_public_key else "r1"
177
+
178
+ kas_locator = ResourceLocator(kas_url, kas_id)
179
+
180
+ # Get ECC mode from config or use default
181
+ ecc_mode = ECCMode(0, False)
182
+ if config.ecc_mode:
183
+ if isinstance(config.ecc_mode, str):
184
+ ecc_mode = ECCMode.from_string(config.ecc_mode)
185
+ else:
186
+ ecc_mode = config.ecc_mode
187
+
188
+ # Default payload config
189
+ # Use cipher_type=5 for AES-256-GCM with 128-bit tag (16 bytes)
190
+ # This matches Python's cryptography AESGCM default
191
+ payload_config = SymmetricAndPayloadConfig(5, 0, False)
192
+
193
+ # Create policy info
194
+ policy_info = PolicyInfo()
195
+ if policy_type == "EMBEDDED_POLICY_PLAIN_TEXT":
196
+ policy_info.set_embedded_plain_text_policy(policy_body)
197
+ else:
198
+ policy_info.set_embedded_encrypted_text_policy(policy_body)
199
+
200
+ # Create policy binding (GMAC)
201
+ policy_binding = hashlib.sha256(policy_body).digest()[
202
+ -self.K_NANOTDF_GMAC_LENGTH :
203
+ ]
204
+
205
+ # Build the header
206
+ header = Header()
207
+ header.set_kas_locator(kas_locator)
208
+ header.set_ecc_mode(ecc_mode)
209
+ header.set_payload_config(payload_config)
210
+ header.set_policy_info(policy_info)
211
+ header.policy_binding = policy_binding
212
+
213
+ # Set ephemeral key - use provided ECDH key or generate random placeholder
214
+ if ephemeral_public_key:
215
+ header.set_ephemeral_key(ephemeral_public_key)
216
+ else:
217
+ # Fallback: generate random bytes as placeholder (for symmetric key case)
218
+ header.set_ephemeral_key(
219
+ secrets.token_bytes(
220
+ ECCMode.get_ec_compressed_pubkey_size(
221
+ ecc_mode.get_elliptic_curve_type()
222
+ )
223
+ )
224
+ )
225
+
226
+ # Generate and return the header bytes with magic number
227
+ header_bytes = header.to_bytes()
228
+ return self.MAGIC_NUMBER_AND_VERSION + header_bytes
229
+
230
+ def _is_ec_key(self, key_pem: str) -> bool:
231
+ """
232
+ Detect if a PEM key is an EC key (vs RSA).
233
+
234
+ Args:
235
+ key_pem: PEM-formatted key string
236
+
237
+ Returns:
238
+ bool: True if EC key, False if RSA key
239
+
240
+ Raises:
241
+ SDKException: If key cannot be parsed
242
+ """
243
+ try:
244
+ # Try to load as public key first
245
+ if "BEGIN PUBLIC KEY" in key_pem or "BEGIN CERTIFICATE" in key_pem:
246
+ if "BEGIN CERTIFICATE" in key_pem:
247
+ from cryptography.x509 import load_pem_x509_certificate
248
+
249
+ cert = load_pem_x509_certificate(key_pem.encode())
250
+ public_key = cert.public_key()
251
+ else:
252
+ public_key = serialization.load_pem_public_key(key_pem.encode())
253
+ return isinstance(public_key, ec.EllipticCurvePublicKey)
254
+ # Try to load as private key
255
+ elif "BEGIN" in key_pem and "PRIVATE KEY" in key_pem:
256
+ private_key = serialization.load_pem_private_key(
257
+ key_pem.encode(), password=None
258
+ )
259
+ return isinstance(private_key, ec.EllipticCurvePrivateKey)
260
+ else:
261
+ raise SDKException("Invalid PEM format - no BEGIN header found")
262
+ except Exception as e:
263
+ raise SDKException(f"Failed to detect key type: {e}")
264
+
265
+ def _derive_key_with_ecdh( # noqa: C901
266
+ self, config: NanoTDFConfig
267
+ ) -> tuple[bytes, bytes | None, bytes | None]:
268
+ """
269
+ Derive encryption key using ECDH if KAS public key is provided or can be fetched.
270
+
271
+ This implements the NanoTDF spec's ECDH + HKDF key derivation:
272
+ 1. Generate ephemeral keypair
273
+ 2. Perform ECDH with KAS public key to get shared secret
274
+ 3. Use HKDF to derive symmetric key from shared secret
275
+
276
+ For backward compatibility, also supports RSA key wrapping when an RSA key is detected.
277
+
278
+ Args:
279
+ config: NanoTDFConfig with potential KASInfo and ECC mode
280
+
281
+ Returns:
282
+ tuple: (derived_key, ephemeral_public_key_compressed, kas_public_key)
283
+ - derived_key: 32-byte AES-256 key for encrypting the payload
284
+ - ephemeral_public_key_compressed: Compressed ephemeral public key to store in header (None for RSA)
285
+ - kas_public_key: KAS public key PEM string (or None if not available)
286
+ """
287
+ import logging
288
+
289
+ from otdf_python.ecdh import encrypt_key_with_ecdh
290
+
291
+ kas_public_key = None
292
+ derived_key = None
293
+ ephemeral_public_key_compressed = None
294
+
295
+ if config.kas_info_list and len(config.kas_info_list) > 0:
296
+ # Get the first KASInfo with a public_key or fetch it
297
+ for kas_info in config.kas_info_list:
298
+ if kas_info.public_key:
299
+ kas_public_key = kas_info.public_key
300
+ break
301
+ elif self.services:
302
+ # Try to fetch public key from KAS service
303
+ try:
304
+ # For NanoTDF, prefer EC keys for ECDH - set algorithm if not specified
305
+ if not kas_info.algorithm:
306
+ # Default to EC secp256r1 for NanoTDF ECDH
307
+ kas_info.algorithm = "ec:secp256r1"
308
+ logging.info(
309
+ f"Fetching EC public key from KAS for NanoTDF ECDH: {kas_info.url}"
310
+ )
311
+ else:
312
+ logging.info(
313
+ f"Fetching public key (algorithm={kas_info.algorithm}) from KAS: {kas_info.url}"
314
+ )
315
+
316
+ updated_kas = self.services.kas().get_public_key(kas_info)
317
+ kas_public_key = updated_kas.public_key
318
+ # Update the config with the fetched public key
319
+ kas_info.public_key = kas_public_key
320
+ break
321
+ except Exception as e:
322
+ logging.warning(
323
+ f"Failed to fetch public key from KAS {kas_info.url}: {e}"
324
+ )
325
+ # Continue to next KAS or proceed without wrapping
326
+
327
+ if kas_public_key:
328
+ # Detect if key is EC or RSA
329
+ is_ec = self._is_ec_key(kas_public_key)
330
+
331
+ if is_ec:
332
+ # EC key - use ECDH + HKDF
333
+ # Determine curve from config
334
+ curve_name = "secp256r1" # Default
335
+ if config.ecc_mode:
336
+ if isinstance(config.ecc_mode, str):
337
+ # Parse the string to get actual curve name
338
+ # Handles cases like "gmac" or "ecdsa" which map to secp256r1
339
+ try:
340
+ ecc_mode_obj = ECCMode.from_string(config.ecc_mode)
341
+ curve_name = ecc_mode_obj.get_curve_name()
342
+ except (ValueError, AttributeError):
343
+ # If parsing fails, stick with default
344
+ logging.warning(
345
+ f"Could not parse ecc_mode '{config.ecc_mode}', using default secp256r1"
346
+ )
347
+ curve_name = "secp256r1"
348
+ else:
349
+ # Get curve name from ECCMode object
350
+ curve_name = config.ecc_mode.get_curve_name()
351
+
352
+ try:
353
+ # Use ECDH to derive key and generate ephemeral keypair
354
+ derived_key, ephemeral_public_key_compressed = (
355
+ encrypt_key_with_ecdh(kas_public_key, curve_name=curve_name)
356
+ )
357
+ logging.info(
358
+ f"Successfully derived NanoTDF key using ECDH with curve {curve_name}"
359
+ )
360
+ except Exception as e:
361
+ logging.warning(f"Failed to derive key with ECDH: {e}")
362
+ derived_key = None
363
+ ephemeral_public_key_compressed = None
364
+ else:
365
+ # RSA key - use RSA wrapping for backward compatibility
366
+ try:
367
+ # Generate random symmetric key
368
+ derived_key = secrets.token_bytes(32)
369
+ # For RSA mode, we don't use ephemeral keys - the symmetric key
370
+ # will be wrapped by KAS using RSA
371
+ ephemeral_public_key_compressed = None
372
+ logging.info(
373
+ "Generated symmetric key for RSA wrapping (backward compatibility)"
374
+ )
375
+ except Exception as e:
376
+ logging.warning(f"Failed to generate key for RSA wrapping: {e}")
377
+ derived_key = None
378
+ ephemeral_public_key_compressed = None
379
+ else:
380
+ logging.warning(
381
+ "No KAS public key available - creating NanoTDF without key derivation"
382
+ )
383
+
384
+ return derived_key, ephemeral_public_key_compressed, kas_public_key
385
+
386
+ def _encrypt_payload(self, payload: bytes, key: bytes) -> tuple[bytes, bytes]:
387
+ """
388
+ Encrypt the payload using AES-GCM.
389
+
390
+ Args:
391
+ payload: The payload to encrypt
392
+ key: The encryption key
393
+
394
+ Returns:
395
+ tuple: (iv, ciphertext)
396
+ """
397
+ iv = secrets.token_bytes(self.K_NANOTDF_IV_SIZE)
398
+ iv_padded = self.K_EMPTY_IV[: self.K_IV_PADDING] + iv
399
+ aesgcm = AESGCM(key)
400
+ ciphertext = aesgcm.encrypt(iv_padded, payload, None)
401
+ return iv, ciphertext
402
+
403
+ def create_nano_tdf(
404
+ self, payload: bytes | BytesIO, output_stream: BinaryIO, config: NanoTDFConfig
405
+ ) -> int:
406
+ """
407
+ Stream-based NanoTDF creation - writes encrypted payload to an output stream.
408
+
409
+ For convenience method that returns bytes, use create_nanotdf() instead.
410
+ Supports ECDH key derivation if KAS info with public key is provided in config.
411
+
412
+ Args:
413
+ payload: The payload data as bytes or BytesIO
414
+ output_stream: The output stream to write the NanoTDF to
415
+ config: NanoTDFConfig configuration for the NanoTDF creation
416
+
417
+ Returns:
418
+ int: The size of the created NanoTDF
419
+
420
+ Raises:
421
+ NanoTDFMaxSizeLimit: If the payload exceeds the maximum size
422
+ UnsupportedNanoTDFFeature: If an unsupported feature is requested
423
+ InvalidNanoTDFConfig: If the configuration is invalid
424
+ SDKException: For other errors
425
+ """
426
+
427
+ # Process payload and validate size
428
+ payload = self._prepare_payload(payload)
429
+
430
+ # Process policy data
431
+ policy_body, policy_type = self._prepare_policy_data(config)
432
+
433
+ # Try to derive key using ECDH or RSA
434
+ (
435
+ derived_key,
436
+ ephemeral_public_key_compressed,
437
+ kas_public_key, # noqa: RUF059
438
+ ) = self._derive_key_with_ecdh(config)
439
+
440
+ # Use ECDH-derived key if available; otherwise use/generate symmetric key
441
+ # Fallback to symmetric key (for testing or when KAS is not available)
442
+ key = derived_key or self._prepare_encryption_key(config)
443
+
444
+ # Create header with ephemeral public key (if ECDH was used)
445
+ header_bytes = self._create_header(
446
+ policy_body, policy_type, config, ephemeral_public_key_compressed
447
+ )
448
+ output_stream.write(header_bytes)
449
+
450
+ # Encrypt payload
451
+ iv, ciphertext_with_tag = self._encrypt_payload(payload, key)
452
+
453
+ # NanoTDF payload format per spec:
454
+ # [3 bytes: length] [3 bytes: IV] [variable: ciphertext] [tag]
455
+ # Note: ciphertext_with_tag from AESGCM already includes the tag
456
+ payload_data = iv + ciphertext_with_tag
457
+ payload_length = len(payload_data)
458
+
459
+ # Write payload length as 3 bytes (big-endian)
460
+ length_bytes = payload_length.to_bytes(4, "big")[1:] # Take last 3 bytes
461
+ output_stream.write(length_bytes)
462
+
463
+ # Write payload (IV + ciphertext + tag)
464
+ output_stream.write(payload_data)
465
+
466
+ return len(header_bytes) + 3 + payload_length
467
+
468
+ def _kas_unwrap(
469
+ self, nano_tdf_data: bytes, header_len: int, wrapped_key: bytes
470
+ ) -> bytes | None:
471
+ try:
472
+ # For NanoTDF, send the entire header to KAS
473
+ # KAS will extract the policy, ephemeral key, and perform ECDH
474
+ import logging
475
+
476
+ from otdf_python.header import Header
477
+ from otdf_python.kas_client import KeyAccess
478
+
479
+ # Extract header bytes (excluding magic number/version which is at start of nano_tdf_data)
480
+ # The header starts at offset 0 (magic number) and goes for header_len bytes
481
+ header_bytes = nano_tdf_data[:header_len]
482
+
483
+ # Parse just to get KAS URL (we still need this for routing)
484
+ header_obj = Header.from_bytes(header_bytes)
485
+ kas_url = header_obj.kas_locator.get_resource_url()
486
+
487
+ # Get KAS client from services
488
+ kas_client = self.services.kas()
489
+
490
+ # For NanoTDF: Pass header bytes to KAS
491
+ # KAS will extract ephemeral key, decrypt policy if needed, and derive/unwrap the key
492
+ # Use minimal policy JSON since KAS will extract it from the header
493
+ policy_json = '{"uuid":"00000000-0000-0000-0000-000000000000","body":{"dataAttributes":[]}}'
494
+
495
+ key_access = KeyAccess(
496
+ url=kas_url,
497
+ wrapped_key="", # NanoTDF uses ECDH, not wrapped keys
498
+ header=header_bytes, # Send entire header to KAS
499
+ )
500
+
501
+ # Use EC key type for NanoTDF (always uses ECDH)
502
+ from otdf_python.key_type_constants import EC_KEY_TYPE
503
+
504
+ key = kas_client.unwrap(key_access, policy_json, EC_KEY_TYPE)
505
+ logging.info("Successfully unwrapped NanoTDF key using KAS with header")
506
+
507
+ except Exception as e:
508
+ # If KAS unwrap fails, log and fall through to local unwrap methods
509
+ import logging
510
+
511
+ logging.warning(f"KAS unwrap failed for NanoTDF: {e}, trying local unwrap")
512
+ key = None
513
+
514
+ return key
515
+
516
+ def _local_unwrap(self, wrapped_key: bytes, config: NanoTDFConfig) -> bytes:
517
+ """Unwrap key locally using private key or mock unwrap (for testing/offline use)."""
518
+ kas_private_key = None
519
+ # Try to get from cipher field if it looks like a PEM key
520
+ if (
521
+ config.cipher
522
+ and isinstance(config.cipher, str)
523
+ and "-----BEGIN" in config.cipher
524
+ ):
525
+ kas_private_key = config.cipher
526
+
527
+ # Check if mock unwrap is enabled in config string
528
+ kas_mock_unwrap = False
529
+ if config.config and "mock_unwrap=true" in config.config.lower():
530
+ kas_mock_unwrap = True
531
+
532
+ if not kas_private_key and not kas_mock_unwrap:
533
+ raise InvalidNanoTDFConfig(
534
+ "Unable to unwrap NanoTDF key: KAS unwrap failed and no local private key available. "
535
+ "Ensure SDK has valid credentials or provide kas_private_key in config for offline use."
536
+ )
537
+
538
+ if kas_mock_unwrap:
539
+ # Use the KAS mock unwrap_nanotdf logic
540
+ from otdf_python.sdk import KAS
541
+
542
+ return KAS().unwrap_nanotdf(
543
+ curve=None,
544
+ header=None,
545
+ kas_url=None,
546
+ wrapped_key=wrapped_key,
547
+ kas_private_key=kas_private_key,
548
+ mock=True,
549
+ )
550
+ else:
551
+ asym = AsymDecryption(kas_private_key)
552
+ return asym.decrypt(wrapped_key)
553
+
554
+ def read_nano_tdf( # noqa: C901
555
+ self,
556
+ nano_tdf_data: bytes | BytesIO,
557
+ output_stream: BinaryIO,
558
+ config: NanoTDFConfig,
559
+ ) -> None:
560
+ """
561
+ Stream-based NanoTDF decryption - writes decrypted payload to an output stream.
562
+
563
+ For convenience method that returns bytes, use read_nanotdf() instead.
564
+ Supports ECDH key derivation and KAS key unwrapping.
565
+
566
+ Args:
567
+ nano_tdf_data: The NanoTDF data as bytes or BytesIO
568
+ output_stream: The output stream to write the payload to
569
+ config: Configuration for the NanoTDF reader
570
+
571
+ Raises:
572
+ InvalidNanoTDFConfig: If the NanoTDF format is invalid or config is missing required info
573
+ SDKException: For other errors
574
+ """
575
+ # Convert to bytes if BytesIO
576
+ if isinstance(nano_tdf_data, BytesIO):
577
+ nano_tdf_data = nano_tdf_data.getvalue()
578
+
579
+ from otdf_python.header import Header # Local import to avoid circular import
580
+
581
+ try:
582
+ header_len = Header.peek_length(nano_tdf_data)
583
+ header_obj = Header.from_bytes(nano_tdf_data[:header_len])
584
+ except Exception as e:
585
+ raise InvalidNanoTDFConfig(f"Failed to parse NanoTDF header: {e}")
586
+
587
+ # Read payload section per NanoTDF spec:
588
+ # [3 bytes: length] [3 bytes: IV] [variable: ciphertext] [tag]
589
+ payload_offset = header_len
590
+
591
+ # Read 3-byte payload length
592
+ payload_length = int.from_bytes(
593
+ nano_tdf_data[payload_offset : payload_offset + 3], "big"
594
+ )
595
+ payload_offset += 3
596
+
597
+ # Read payload data (IV + ciphertext + tag)
598
+ payload = nano_tdf_data[payload_offset : payload_offset + payload_length]
599
+
600
+ # Extract IV (first 3 bytes)
601
+ iv = payload[0:3]
602
+ iv_padded = self.K_EMPTY_IV[: self.K_IV_PADDING] + iv
603
+
604
+ # The rest is ciphertext + tag
605
+ ciphertext_with_tag = payload[3:]
606
+
607
+ key = None
608
+
609
+ import logging
610
+
611
+ from otdf_python.ecdh import decrypt_key_with_ecdh
612
+
613
+ # Extract ephemeral public key from header
614
+ ephemeral_public_key = header_obj.ephemeral_key
615
+ ecc_mode = header_obj.ecc_mode
616
+
617
+ # Get curve name from ECC mode
618
+ curve_name = ecc_mode.get_curve_name() # e.g., "secp256r1"
619
+
620
+ # Try KAS unwrap first if services available
621
+ if self.services:
622
+ try:
623
+ key = self._kas_unwrap(nano_tdf_data, header_len, wrapped_key=b"")
624
+ if key:
625
+ logging.info(
626
+ "Successfully unwrapped NanoTDF key via KAS (ECDH mode)"
627
+ )
628
+ except Exception as e:
629
+ logging.warning(f"KAS unwrap failed for ECDH mode: {e}")
630
+ key = None
631
+
632
+ # If KAS unwrap didn't work, try local private key from config
633
+ if not key:
634
+ recipient_private_key_pem = None
635
+ if config and hasattr(config, "cipher") and isinstance(config.cipher, str):
636
+ if "-----BEGIN" in config.cipher:
637
+ # It's a PEM private key
638
+ recipient_private_key_pem = config.cipher
639
+ else:
640
+ # Try to parse as hex symmetric key (fallback)
641
+ with contextlib.suppress(ValueError):
642
+ key = bytes.fromhex(config.cipher)
643
+
644
+ # If we have a private key, detect type and use appropriate method
645
+ if recipient_private_key_pem:
646
+ # Detect if key is EC or RSA
647
+ is_ec = self._is_ec_key(recipient_private_key_pem)
648
+
649
+ if is_ec:
650
+ # EC key - use ECDH to derive the decryption key
651
+ try:
652
+ key = decrypt_key_with_ecdh(
653
+ recipient_private_key_pem,
654
+ ephemeral_public_key,
655
+ curve_name=curve_name,
656
+ )
657
+ logging.info(
658
+ f"Successfully derived NanoTDF decryption key using ECDH with curve {curve_name}"
659
+ )
660
+ except Exception as e:
661
+ logging.warning(f"Failed to derive key with ECDH: {e}")
662
+ key = None
663
+ else:
664
+ # RSA key - this shouldn't happen for ECDH mode (wrapped_key_len should be > 0)
665
+ # But handle it gracefully
666
+ logging.warning(
667
+ "RSA private key provided for ECDH mode NanoTDF - this is unexpected. "
668
+ "NanoTDF should use wrapped_key_len > 0 for RSA mode."
669
+ )
670
+ key = None
671
+
672
+ # If no key yet, raise error
673
+ if not key:
674
+ raise InvalidNanoTDFConfig(
675
+ "Missing decryption key. Provide either:\n"
676
+ " 1. KAS service for key unwrapping, or\n"
677
+ " 2. Recipient's private key (PEM format) in config.cipher for ECDH, or\n"
678
+ " 3. Symmetric key (hex) in config.cipher for symmetric decryption"
679
+ )
680
+
681
+ # Decrypt the ciphertext using AES-GCM
682
+ # Use cipher type from header to determine tag size
683
+ import logging
684
+
685
+ tag_size_map = {
686
+ 0: 8, # 64-bit
687
+ 1: 12, # 96-bit
688
+ 2: 13, # 104-bit
689
+ 3: 14, # 112-bit
690
+ 4: 15, # 120-bit
691
+ 5: 16, # 128-bit
692
+ }
693
+
694
+ cipher_type = (
695
+ header_obj.payload_config.get_cipher_type()
696
+ if header_obj.payload_config
697
+ else 5
698
+ )
699
+ tag_size = tag_size_map.get(cipher_type, 16)
700
+
701
+ logging.info(
702
+ f"Decrypting payload: key_len={len(key)}, key_hex={key.hex()[:40]}..., iv_3byte={iv.hex()}, iv_padded={iv_padded.hex()}, cipher_type={cipher_type}, tag_size={tag_size}, ciphertext_len={len(ciphertext_with_tag)}"
703
+ )
704
+
705
+ # For variable tag sizes, use lower-level Cipher API
706
+ from cryptography.hazmat.backends import default_backend
707
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
708
+
709
+ # Split ciphertext and tag
710
+ ciphertext = ciphertext_with_tag[:-tag_size]
711
+ tag = ciphertext_with_tag[-tag_size:]
712
+
713
+ logging.info(
714
+ f"Split: ciphertext={len(ciphertext)} bytes, tag={len(tag)} bytes ({tag.hex()})"
715
+ )
716
+
717
+ # Create cipher with GCM mode specifying tag and min_tag_length
718
+ cipher = Cipher(
719
+ algorithms.AES(key),
720
+ modes.GCM(iv_padded, tag=tag, min_tag_length=tag_size),
721
+ backend=default_backend(),
722
+ )
723
+ decryptor = cipher.decryptor()
724
+ plaintext = decryptor.update(ciphertext) + decryptor.finalize()
725
+ output_stream.write(plaintext)
726
+
727
+ def _convert_dict_to_nanotdf_config(self, config: dict) -> NanoTDFConfig:
728
+ """Convert a dictionary config to a NanoTDFConfig object."""
729
+ converted_config = NanoTDFConfig()
730
+ if "attributes" in config:
731
+ converted_config.attributes = config["attributes"]
732
+ if "key" in config:
733
+ converted_config.cipher = (
734
+ config["key"].hex()
735
+ if isinstance(config["key"], bytes)
736
+ else config["key"]
737
+ )
738
+ if "kas_public_key" in config:
739
+ kas_info = KASInfo(
740
+ url="https://kas.example.com", public_key=config["kas_public_key"]
741
+ )
742
+ converted_config.kas_info_list = [kas_info]
743
+ if "policy_type" in config:
744
+ converted_config.policy_type = config["policy_type"]
745
+ return converted_config
746
+
747
+ def _handle_legacy_key_config(
748
+ self, config: dict | NanoTDFConfig
749
+ ) -> tuple[bytes, dict | NanoTDFConfig]:
750
+ """Handle key configuration for legacy method."""
751
+ key = None
752
+ if isinstance(config, dict) and "key" in config:
753
+ key = config["key"]
754
+ elif (
755
+ hasattr(config, "cipher")
756
+ and config.cipher
757
+ and isinstance(config.cipher, str)
758
+ and all(c in "0123456789abcdefABCDEF" for c in config.cipher)
759
+ ):
760
+ key = bytes.fromhex(config.cipher)
761
+
762
+ if not key:
763
+ key = secrets.token_bytes(32)
764
+ if isinstance(config, dict):
765
+ config["key"] = key
766
+ else:
767
+ config.cipher = key.hex()
768
+ return key, config
769
+
770
+ def create_nanotdf(self, data: bytes, config: dict | NanoTDFConfig) -> bytes:
771
+ """
772
+ Convenience method - creates a NanoTDF and returns the encrypted bytes.
773
+
774
+ For stream-based version, use create_nano_tdf() instead.
775
+ """
776
+ if len(data) > self.K_MAX_TDF_SIZE:
777
+ raise NanoTDFMaxSizeLimit("exceeds max size for nano tdf")
778
+
779
+ # If config is already a NanoTDFConfig, use it; otherwise create one
780
+ if not isinstance(config, NanoTDFConfig):
781
+ config = self._convert_dict_to_nanotdf_config(config)
782
+
783
+ # Create output buffer
784
+ output = BytesIO()
785
+
786
+ # Create NanoTDF using the new method
787
+ self.create_nano_tdf(data, output, config)
788
+
789
+ # Return the bytes
790
+ output.seek(0)
791
+ return output.getvalue()
792
+ # Header construction, based on Java implementation
793
+ # This method now uses the more modular create_nano_tdf method
794
+
795
+ def _convert_dict_to_read_config(self, config: dict) -> NanoTDFConfig:
796
+ """Convert a dictionary config to a NanoTDFConfig object for reading."""
797
+ converted_config = NanoTDFConfig()
798
+ if "key" in config:
799
+ converted_config.cipher = (
800
+ config["key"].hex()
801
+ if isinstance(config["key"], bytes)
802
+ else config["key"]
803
+ )
804
+ if "kas_private_key" in config:
805
+ converted_config.cipher = config["kas_private_key"]
806
+ return converted_config
807
+
808
+ def _extract_key_for_reading(
809
+ self, config: dict | NanoTDFConfig | None, wrapped_key: bytes | None
810
+ ) -> bytes:
811
+ """Extract the decryption key from config or unwrap it."""
812
+ # For wrapped key case
813
+ if wrapped_key:
814
+ kas_private_key = None
815
+ if isinstance(config, dict):
816
+ kas_private_key = config.get("kas_private_key")
817
+ elif (
818
+ config
819
+ and config.cipher
820
+ and isinstance(config.cipher, str)
821
+ and "-----BEGIN" in config.cipher
822
+ ):
823
+ kas_private_key = config.cipher
824
+
825
+ if not kas_private_key:
826
+ raise InvalidNanoTDFConfig("Missing kas_private_key for unwrap.")
827
+
828
+ asym = AsymDecryption(kas_private_key)
829
+ return asym.decrypt(wrapped_key)
830
+
831
+ # For symmetric key case
832
+ key = None
833
+ if isinstance(config, dict):
834
+ key = config.get("key")
835
+ elif (
836
+ config
837
+ and config.cipher
838
+ and isinstance(config.cipher, str)
839
+ and all(c in "0123456789abcdefABCDEF" for c in config.cipher)
840
+ ):
841
+ key = bytes.fromhex(config.cipher)
842
+ if not key:
843
+ raise InvalidNanoTDFConfig("Missing decryption key in config.")
844
+ return key
845
+
846
+ def read_nanotdf(
847
+ self, nanotdf_bytes: bytes, config: dict | NanoTDFConfig | None = None
848
+ ) -> bytes:
849
+ """
850
+ Convenience method - decrypts a NanoTDF and returns the plaintext bytes.
851
+
852
+ For stream-based version, use read_nano_tdf() instead.
853
+ """
854
+ output = BytesIO()
855
+
856
+ # Convert config to NanoTDFConfig if it's a dict
857
+ if isinstance(config, dict):
858
+ config = self._convert_dict_to_read_config(config)
859
+
860
+ # Use the stream-based method internally
861
+ self.read_nano_tdf(nanotdf_bytes, output, config)
862
+
863
+ return output.getvalue()