otdf-python 0.1.10__py3-none-any.whl → 0.3.1__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 +85 -0
  7. otdf_python/asym_decryption.py +53 -0
  8. otdf_python/asym_encryption.py +75 -0
  9. otdf_python/auth_headers.py +21 -0
  10. otdf_python/autoconfigure_utils.py +113 -0
  11. otdf_python/cli.py +570 -0
  12. otdf_python/collection_store.py +41 -0
  13. otdf_python/collection_store_impl.py +22 -0
  14. otdf_python/config.py +69 -0
  15. otdf_python/connect_client.py +0 -0
  16. otdf_python/constants.py +1 -0
  17. otdf_python/crypto_utils.py +78 -0
  18. otdf_python/dpop.py +81 -0
  19. otdf_python/ecc_mode.py +32 -0
  20. otdf_python/eckeypair.py +75 -0
  21. otdf_python/header.py +143 -0
  22. otdf_python/invalid_zip_exception.py +8 -0
  23. otdf_python/kas_client.py +603 -0
  24. otdf_python/kas_connect_rpc_client.py +207 -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 +553 -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 +78 -0
  35. otdf_python/policy_object.py +22 -0
  36. otdf_python/policy_stub.py +2 -0
  37. otdf_python/resource_locator.py +44 -0
  38. otdf_python/sdk.py +528 -0
  39. otdf_python/sdk_builder.py +448 -0
  40. otdf_python/sdk_exceptions.py +16 -0
  41. otdf_python/symmetric_and_payload_config.py +30 -0
  42. otdf_python/tdf.py +479 -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.1.dist-info/METADATA +231 -0
  50. otdf_python-0.3.1.dist-info/RECORD +137 -0
  51. {otdf_python-0.1.10.dist-info → otdf_python-0.3.1.dist-info}/WHEEL +1 -2
  52. {otdf_python-0.1.10.dist-info → otdf_python-0.3.1.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,553 @@
1
+ import hashlib
2
+ import json
3
+ import secrets
4
+ from io import BytesIO
5
+ from typing import BinaryIO
6
+
7
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
8
+
9
+ from otdf_python.asym_crypto import AsymDecryption
10
+ from otdf_python.collection_store import CollectionStore, NoOpCollectionStore
11
+ from otdf_python.config import KASInfo, NanoTDFConfig
12
+ from otdf_python.constants import MAGIC_NUMBER_AND_VERSION
13
+ from otdf_python.ecc_mode import ECCMode
14
+ from otdf_python.policy_info import PolicyInfo
15
+ from otdf_python.policy_object import AttributeObject, PolicyBody, PolicyObject
16
+ from otdf_python.policy_stub import NULL_POLICY_UUID
17
+ from otdf_python.resource_locator import ResourceLocator
18
+ from otdf_python.sdk_exceptions import SDKException
19
+ from otdf_python.symmetric_and_payload_config import SymmetricAndPayloadConfig
20
+
21
+
22
+ class NanoTDFException(SDKException):
23
+ pass
24
+
25
+
26
+ class NanoTDFMaxSizeLimit(NanoTDFException):
27
+ pass
28
+
29
+
30
+ class UnsupportedNanoTDFFeature(NanoTDFException):
31
+ pass
32
+
33
+
34
+ class InvalidNanoTDFConfig(NanoTDFException):
35
+ pass
36
+
37
+
38
+ class NanoTDF:
39
+ MAGIC_NUMBER_AND_VERSION = MAGIC_NUMBER_AND_VERSION
40
+ K_MAX_TDF_SIZE = (16 * 1024 * 1024) - 3 - 32
41
+ K_NANOTDF_GMAC_LENGTH = 8
42
+ K_IV_PADDING = 9
43
+ K_NANOTDF_IV_SIZE = 3
44
+ K_EMPTY_IV = bytes([0x0] * 12)
45
+
46
+ def __init__(self, services=None, collection_store: CollectionStore | None = None):
47
+ self.services = services
48
+ self.collection_store = collection_store or NoOpCollectionStore()
49
+
50
+ def _create_policy_object(self, attributes: list[str]) -> PolicyObject:
51
+ # TODO: Replace this with a proper Policy UUID value
52
+ policy_uuid = NULL_POLICY_UUID
53
+ data_attributes = [AttributeObject(attribute=a) for a in attributes]
54
+ body = PolicyBody(data_attributes=data_attributes, dissem=[])
55
+ return PolicyObject(uuid=policy_uuid, body=body)
56
+
57
+ def _serialize_policy_object(self, obj):
58
+ """Custom NanoTDF serializer to convert to compatible JSON format."""
59
+ from otdf_python.policy_object import AttributeObject, PolicyBody
60
+
61
+ if isinstance(obj, PolicyBody):
62
+ # Convert data_attributes to dataAttributes and use null instead of empty array
63
+ result = {
64
+ "dataAttributes": obj.data_attributes if obj.data_attributes else None,
65
+ "dissem": obj.dissem if obj.dissem else None,
66
+ }
67
+ return result
68
+ elif isinstance(obj, AttributeObject):
69
+ # Convert snake_case field names to camelCase for JSON serialization
70
+ return {
71
+ "attribute": obj.attribute,
72
+ "displayName": obj.display_name,
73
+ "isDefault": obj.is_default,
74
+ "pubKey": obj.pub_key,
75
+ "kasUrl": obj.kas_url,
76
+ }
77
+ else:
78
+ return obj.__dict__
79
+
80
+ def _prepare_payload(self, payload: bytes | BytesIO) -> bytes:
81
+ """
82
+ Convert BytesIO to bytes and validate payload size.
83
+
84
+ Args:
85
+ payload: The payload data as bytes or BytesIO
86
+
87
+ Returns:
88
+ bytes: The payload as bytes
89
+
90
+ Raises:
91
+ NanoTDFMaxSizeLimit: If the payload exceeds the maximum size
92
+ """
93
+ if isinstance(payload, BytesIO):
94
+ payload = payload.getvalue()
95
+ if len(payload) > self.K_MAX_TDF_SIZE:
96
+ raise NanoTDFMaxSizeLimit("exceeds max size for nano tdf")
97
+ return payload
98
+
99
+ def _prepare_policy_data(self, config: NanoTDFConfig) -> tuple[bytes, str]:
100
+ """
101
+ Prepare policy data from configuration.
102
+
103
+ Args:
104
+ config: NanoTDFConfig configuration
105
+
106
+ Returns:
107
+ tuple: (policy_body, policy_type)
108
+ """
109
+ attributes = config.attributes if config.attributes else []
110
+ policy_object = self._create_policy_object(attributes)
111
+ policy_json = json.dumps(
112
+ policy_object, default=self._serialize_policy_object
113
+ ).encode("utf-8")
114
+ policy_type = (
115
+ config.policy_type if config.policy_type else "EMBEDDED_POLICY_PLAIN_TEXT"
116
+ )
117
+
118
+ if policy_type == "EMBEDDED_POLICY_PLAIN_TEXT":
119
+ policy_body = policy_json
120
+ else:
121
+ # Encrypt policy
122
+ policy_key = secrets.token_bytes(32)
123
+ aesgcm = AESGCM(policy_key)
124
+ iv = secrets.token_bytes(12)
125
+ policy_body = aesgcm.encrypt(iv, policy_json, None)
126
+
127
+ return policy_body, policy_type
128
+
129
+ def _prepare_encryption_key(self, config: NanoTDFConfig) -> bytes:
130
+ """Get encryption key from config if provided as hex string, otherwise generate a new random key."""
131
+ key = None
132
+ if (
133
+ config.cipher
134
+ and isinstance(config.cipher, str)
135
+ and all(c in "0123456789abcdefABCDEF" for c in config.cipher)
136
+ ):
137
+ key = bytes.fromhex(config.cipher)
138
+ if not key:
139
+ key = secrets.token_bytes(32)
140
+ return key
141
+
142
+ def _create_header(
143
+ self, policy_body: bytes, policy_type: str, config: NanoTDFConfig
144
+ ) -> bytes:
145
+ """
146
+ Create the NanoTDF header.
147
+
148
+ Args:
149
+ policy_body: The policy body bytes
150
+ policy_type: The policy type string
151
+ config: NanoTDFConfig configuration
152
+
153
+ Returns:
154
+ bytes: The header bytes
155
+ """
156
+ from otdf_python.header import Header # Local import to avoid circular import
157
+
158
+ # KAS URL from KASInfo or default
159
+ kas_url = "https://kas.example.com"
160
+ if config.kas_info_list and len(config.kas_info_list) > 0:
161
+ kas_url = config.kas_info_list[0].url
162
+
163
+ kas_id = "kas-id" # Default KAS ID
164
+ kas_locator = ResourceLocator(kas_url, kas_id)
165
+
166
+ # Get ECC mode from config or use default
167
+ ecc_mode = ECCMode(0, False)
168
+ if config.ecc_mode:
169
+ if isinstance(config.ecc_mode, str):
170
+ ecc_mode = ECCMode.from_string(config.ecc_mode)
171
+ else:
172
+ ecc_mode = config.ecc_mode
173
+
174
+ # Default payload config
175
+ payload_config = SymmetricAndPayloadConfig(0, 0, False)
176
+
177
+ # Create policy info
178
+ policy_info = PolicyInfo()
179
+ if policy_type == "EMBEDDED_POLICY_PLAIN_TEXT":
180
+ policy_info.set_embedded_plain_text_policy(policy_body)
181
+ else:
182
+ policy_info.set_embedded_encrypted_text_policy(policy_body)
183
+ policy_info.set_policy_binding(
184
+ hashlib.sha256(policy_body).digest()[-self.K_NANOTDF_GMAC_LENGTH :]
185
+ )
186
+
187
+ # Build the header
188
+ header = Header()
189
+ header.set_kas_locator(kas_locator)
190
+ header.set_ecc_mode(ecc_mode)
191
+ header.set_payload_config(payload_config)
192
+ header.set_policy_info(policy_info)
193
+ header.set_ephemeral_key(
194
+ secrets.token_bytes(
195
+ ECCMode.get_ec_compressed_pubkey_size(
196
+ ecc_mode.get_elliptic_curve_type()
197
+ )
198
+ )
199
+ )
200
+
201
+ # Generate and return the header bytes with magic number
202
+ header_bytes = header.to_bytes()
203
+ return self.MAGIC_NUMBER_AND_VERSION + header_bytes
204
+
205
+ def _wrap_key_if_needed(
206
+ self, key: bytes, config: NanoTDFConfig
207
+ ) -> tuple[bytes, bytes | None]:
208
+ """
209
+ Wrap encryption key if KAS public key is provided.
210
+
211
+ Args:
212
+ key: The encryption key
213
+ config: NanoTDFConfig with potential KASInfo
214
+
215
+ Returns:
216
+ tuple: (wrapped_key, kas_public_key)
217
+ """
218
+ kas_public_key = None
219
+ wrapped_key = None
220
+
221
+ if config.kas_info_list and len(config.kas_info_list) > 0:
222
+ # Get the first KASInfo with a public_key
223
+ for kas_info in config.kas_info_list:
224
+ if kas_info.public_key:
225
+ kas_public_key = kas_info.public_key
226
+ break
227
+
228
+ if kas_public_key:
229
+ from cryptography.hazmat.backends import default_backend
230
+ from cryptography.hazmat.primitives import hashes, serialization
231
+ from cryptography.hazmat.primitives.asymmetric import padding
232
+
233
+ public_key = serialization.load_pem_public_key(
234
+ kas_public_key.encode(), backend=default_backend()
235
+ )
236
+ wrapped_key = public_key.encrypt(
237
+ key,
238
+ padding.OAEP(
239
+ mgf=padding.MGF1(algorithm=hashes.SHA1()),
240
+ algorithm=hashes.SHA1(),
241
+ label=None,
242
+ ),
243
+ )
244
+
245
+ return wrapped_key, kas_public_key
246
+
247
+ def _encrypt_payload(self, payload: bytes, key: bytes) -> tuple[bytes, bytes]:
248
+ """
249
+ Encrypt the payload using AES-GCM.
250
+
251
+ Args:
252
+ payload: The payload to encrypt
253
+ key: The encryption key
254
+
255
+ Returns:
256
+ tuple: (iv, ciphertext)
257
+ """
258
+ iv = secrets.token_bytes(self.K_NANOTDF_IV_SIZE)
259
+ iv_padded = self.K_EMPTY_IV[: self.K_IV_PADDING] + iv
260
+ aesgcm = AESGCM(key)
261
+ ciphertext = aesgcm.encrypt(iv_padded, payload, None)
262
+ return iv, ciphertext
263
+
264
+ def create_nano_tdf(
265
+ self, payload: bytes | BytesIO, output_stream: BinaryIO, config: NanoTDFConfig
266
+ ) -> int:
267
+ """
268
+ Creates a NanoTDF with the provided payload and writes it to the output stream.
269
+ Supports KAS key wrapping if KAS info with public key is provided in config.
270
+
271
+ Args:
272
+ payload: The payload data as bytes or BytesIO
273
+ output_stream: The output stream to write the NanoTDF to
274
+ config: NanoTDFConfig configuration for the NanoTDF creation
275
+
276
+ Returns:
277
+ int: The size of the created NanoTDF
278
+
279
+ Raises:
280
+ NanoTDFMaxSizeLimit: If the payload exceeds the maximum size
281
+ UnsupportedNanoTDFFeature: If an unsupported feature is requested
282
+ InvalidNanoTDFConfig: If the configuration is invalid
283
+ SDKException: For other errors
284
+ """
285
+
286
+ # Process payload and validate size
287
+ payload = self._prepare_payload(payload)
288
+
289
+ # Process policy data
290
+ policy_body, policy_type = self._prepare_policy_data(config)
291
+
292
+ # Get or generate encryption key
293
+ key = self._prepare_encryption_key(config)
294
+
295
+ # Create header and write to output
296
+ header_bytes = self._create_header(policy_body, policy_type, config)
297
+ output_stream.write(header_bytes)
298
+
299
+ # Encrypt payload
300
+ iv, ciphertext = self._encrypt_payload(payload, key)
301
+
302
+ # Wrap key if needed
303
+ wrapped_key, kas_public_key = self._wrap_key_if_needed(key, config)
304
+
305
+ # Compose the complete NanoTDF: [IV][CIPHERTEXT][WRAPPED_KEY][WRAPPED_KEY_LEN]
306
+ if wrapped_key:
307
+ nano_tdf_data = (
308
+ iv + ciphertext + wrapped_key + len(wrapped_key).to_bytes(2, "big")
309
+ )
310
+ else:
311
+ nano_tdf_data = iv + ciphertext + (0).to_bytes(2, "big")
312
+
313
+ output_stream.write(nano_tdf_data)
314
+ return len(header_bytes) + len(nano_tdf_data)
315
+
316
+ def read_nano_tdf(
317
+ self,
318
+ nano_tdf_data: bytes | BytesIO,
319
+ output_stream: BinaryIO,
320
+ config: NanoTDFConfig,
321
+ platform_url: str | None = None,
322
+ ) -> None:
323
+ """
324
+ Reads a NanoTDF and writes the payload to the output stream.
325
+ Supports KAS key unwrapping if kas_private_key is provided in config.
326
+
327
+ Args:
328
+ nano_tdf_data: The NanoTDF data as bytes or BytesIO
329
+ output_stream: The output stream to write the payload to
330
+ config: Configuration for the NanoTDF reader
331
+ platform_url: Optional platform URL for KAS resolution
332
+
333
+ Raises:
334
+ InvalidNanoTDFConfig: If the NanoTDF format is invalid or config is missing required info
335
+ SDKException: For other errors
336
+ """
337
+ # Convert to bytes if BytesIO
338
+ if isinstance(nano_tdf_data, BytesIO):
339
+ nano_tdf_data = nano_tdf_data.getvalue()
340
+
341
+ from otdf_python.header import Header # Local import to avoid circular import
342
+
343
+ try:
344
+ header_len = Header.peek_length(nano_tdf_data)
345
+ except Exception:
346
+ raise InvalidNanoTDFConfig("Failed to parse NanoTDF header.")
347
+ payload_start = header_len
348
+ payload = nano_tdf_data[payload_start:]
349
+ # Do not check for magic/version in payload; it is only at the start of the header
350
+ iv = payload[0:3]
351
+ iv_padded = self.K_EMPTY_IV[: self.K_IV_PADDING] + iv
352
+ # Find wrapped key
353
+ wrapped_key_len = int.from_bytes(payload[-2:], "big")
354
+ if wrapped_key_len > 0:
355
+ wrapped_key = payload[-(2 + wrapped_key_len) : -2]
356
+
357
+ # Get private key and mock unwrap config
358
+ kas_private_key = None
359
+ # Try to get from cipher field if it looks like a PEM key
360
+ if (
361
+ config.cipher
362
+ and isinstance(config.cipher, str)
363
+ and "-----BEGIN" in config.cipher
364
+ ):
365
+ kas_private_key = config.cipher
366
+
367
+ # Check if mock unwrap is enabled in config string
368
+ kas_mock_unwrap = False
369
+ if config.config and "mock_unwrap=true" in config.config.lower():
370
+ kas_mock_unwrap = True
371
+
372
+ if not kas_private_key and not kas_mock_unwrap:
373
+ raise InvalidNanoTDFConfig("Missing kas_private_key for unwrap.")
374
+ if kas_mock_unwrap:
375
+ # Use the KAS mock unwrap_nanotdf logic
376
+ from otdf_python.sdk import KAS
377
+
378
+ key = KAS().unwrap_nanotdf(
379
+ curve=None,
380
+ header=None,
381
+ kas_url=None,
382
+ wrapped_key=wrapped_key,
383
+ kas_private_key=kas_private_key,
384
+ mock=True,
385
+ )
386
+ else:
387
+ asym = AsymDecryption(kas_private_key)
388
+ key = asym.decrypt(wrapped_key)
389
+ ciphertext = payload[3 : -(2 + wrapped_key_len)]
390
+ else:
391
+ key = config.get("key")
392
+ if not key:
393
+ raise InvalidNanoTDFConfig("Missing decryption key in config.")
394
+ ciphertext = payload[3:-2]
395
+ aesgcm = AESGCM(key)
396
+ plaintext = aesgcm.decrypt(iv_padded, ciphertext, None)
397
+ output_stream.write(plaintext)
398
+
399
+ def _convert_dict_to_nanotdf_config(self, config: dict) -> NanoTDFConfig:
400
+ """Convert a dictionary config to a NanoTDFConfig object."""
401
+ converted_config = NanoTDFConfig()
402
+ if "attributes" in config:
403
+ converted_config.attributes = config["attributes"]
404
+ if "key" in config:
405
+ converted_config.cipher = (
406
+ config["key"].hex()
407
+ if isinstance(config["key"], bytes)
408
+ else config["key"]
409
+ )
410
+ if "kas_public_key" in config:
411
+ kas_info = KASInfo(
412
+ url="https://kas.example.com", public_key=config["kas_public_key"]
413
+ )
414
+ converted_config.kas_info_list = [kas_info]
415
+ if "policy_type" in config:
416
+ converted_config.policy_type = config["policy_type"]
417
+ return converted_config
418
+
419
+ def _handle_legacy_key_config(
420
+ self, config: dict | NanoTDFConfig
421
+ ) -> tuple[bytes, dict | NanoTDFConfig]:
422
+ """Handle key configuration for legacy method."""
423
+ key = None
424
+ if isinstance(config, dict) and "key" in config:
425
+ key = config["key"]
426
+ elif (
427
+ hasattr(config, "cipher")
428
+ and config.cipher
429
+ and isinstance(config.cipher, str)
430
+ and all(c in "0123456789abcdefABCDEF" for c in config.cipher)
431
+ ):
432
+ key = bytes.fromhex(config.cipher)
433
+
434
+ if not key:
435
+ key = secrets.token_bytes(32)
436
+ if isinstance(config, dict):
437
+ config["key"] = key
438
+ else:
439
+ config.cipher = key.hex()
440
+ return key, config
441
+
442
+ def create_nanotdf(self, data: bytes, config: dict | NanoTDFConfig) -> bytes:
443
+ """Create a NanoTDF from input data using the provided configuration."""
444
+ if len(data) > self.K_MAX_TDF_SIZE:
445
+ raise NanoTDFMaxSizeLimit("exceeds max size for nano tdf")
446
+
447
+ # If config is already a NanoTDFConfig, use it; otherwise create one
448
+ if not isinstance(config, NanoTDFConfig):
449
+ config = self._convert_dict_to_nanotdf_config(config)
450
+
451
+ # Create output buffer
452
+ output = BytesIO()
453
+
454
+ # Create NanoTDF using the new method
455
+ self.create_nano_tdf(data, output, config)
456
+
457
+ # Return the bytes
458
+ output.seek(0)
459
+ return output.getvalue()
460
+ # Header construction, based on Java implementation
461
+ # This method now uses the more modular create_nano_tdf method
462
+
463
+ def _convert_dict_to_read_config(self, config: dict) -> NanoTDFConfig:
464
+ """Convert a dictionary config to a NanoTDFConfig object for reading."""
465
+ converted_config = NanoTDFConfig()
466
+ if "key" in config:
467
+ converted_config.cipher = (
468
+ config["key"].hex()
469
+ if isinstance(config["key"], bytes)
470
+ else config["key"]
471
+ )
472
+ if "kas_private_key" in config:
473
+ converted_config.cipher = config["kas_private_key"]
474
+ return converted_config
475
+
476
+ def _extract_key_for_reading(
477
+ self, config: dict | NanoTDFConfig | None, wrapped_key: bytes | None
478
+ ) -> bytes:
479
+ """Extract the decryption key from config or unwrap it."""
480
+ # For wrapped key case
481
+ if wrapped_key:
482
+ kas_private_key = None
483
+ if isinstance(config, dict):
484
+ kas_private_key = config.get("kas_private_key")
485
+ elif (
486
+ config
487
+ and config.cipher
488
+ and isinstance(config.cipher, str)
489
+ and "-----BEGIN" in config.cipher
490
+ ):
491
+ kas_private_key = config.cipher
492
+
493
+ if not kas_private_key:
494
+ raise InvalidNanoTDFConfig("Missing kas_private_key for unwrap.")
495
+
496
+ asym = AsymDecryption(kas_private_key)
497
+ return asym.decrypt(wrapped_key)
498
+
499
+ # For symmetric key case
500
+ key = None
501
+ if isinstance(config, dict):
502
+ key = config.get("key")
503
+ elif (
504
+ config
505
+ and config.cipher
506
+ and isinstance(config.cipher, str)
507
+ and all(c in "0123456789abcdefABCDEF" for c in config.cipher)
508
+ ):
509
+ key = bytes.fromhex(config.cipher)
510
+ if not key:
511
+ raise InvalidNanoTDFConfig("Missing decryption key in config.")
512
+ return key
513
+
514
+ def read_nanotdf(
515
+ self, nanotdf_bytes: bytes, config: dict | NanoTDFConfig | None = None
516
+ ) -> bytes:
517
+ """Read and decrypt a NanoTDF, returning the original plaintext data."""
518
+ output = BytesIO()
519
+ from otdf_python.header import Header # Local import to avoid circular import
520
+
521
+ # Convert config to NanoTDFConfig if it's a dict
522
+ if isinstance(config, dict):
523
+ config = self._convert_dict_to_read_config(config)
524
+
525
+ try:
526
+ header_len = Header.peek_length(nanotdf_bytes)
527
+ payload = nanotdf_bytes[header_len:]
528
+
529
+ # Extract components
530
+ iv = payload[0:3]
531
+ iv_padded = self.K_EMPTY_IV[: self.K_IV_PADDING] + iv
532
+ wrapped_key_len = int.from_bytes(payload[-2:], "big")
533
+
534
+ wrapped_key = None
535
+ if wrapped_key_len > 0:
536
+ wrapped_key = payload[-(2 + wrapped_key_len) : -2]
537
+ ciphertext = payload[3 : -(2 + wrapped_key_len)]
538
+ else:
539
+ ciphertext = payload[3:-2]
540
+
541
+ # Get the decryption key
542
+ key = self._extract_key_for_reading(config, wrapped_key)
543
+
544
+ # Decrypt the payload
545
+ aesgcm = AESGCM(key)
546
+ plaintext = aesgcm.decrypt(iv_padded, ciphertext, None)
547
+ output.write(plaintext)
548
+
549
+ except Exception as e:
550
+ # Re-raise with a clearer message
551
+ raise InvalidNanoTDFConfig(f"Error reading NanoTDF: {e!s}")
552
+
553
+ return output.getvalue()
@@ -0,0 +1,132 @@
1
+ """
2
+ NanoTDF ECDSA Signature Structure.
3
+ """
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ class IncorrectNanoTDFECDSASignatureSize(Exception):
9
+ """Exception raised when the signature size is incorrect."""
10
+
11
+ pass
12
+
13
+
14
+ @dataclass
15
+ class NanoTDFECDSAStruct:
16
+ """
17
+ Class to handle ECDSA signature structure for NanoTDF.
18
+
19
+ This structure represents an ECDSA signature as required by the NanoTDF format.
20
+ It consists of r and s values along with their lengths.
21
+ """
22
+
23
+ r_length: bytearray = field(default_factory=lambda: bytearray(1))
24
+ r_value: bytearray = None
25
+ s_length: bytearray = field(default_factory=lambda: bytearray(1))
26
+ s_value: bytearray = None
27
+
28
+ @classmethod
29
+ def from_bytes(
30
+ cls, ecdsa_signature_value: bytes, key_size: int
31
+ ) -> "NanoTDFECDSAStruct":
32
+ """
33
+ Create a NanoTDFECDSAStruct from a byte array.
34
+
35
+ Args:
36
+ ecdsa_signature_value: The signature value as bytes
37
+ key_size: The size of the key in bytes
38
+
39
+ Returns:
40
+ A new NanoTDFECDSAStruct
41
+
42
+ Raises:
43
+ IncorrectNanoTDFECDSASignatureSize: If the signature buffer size is invalid
44
+ """
45
+ if len(ecdsa_signature_value) != (2 * key_size) + 2:
46
+ raise IncorrectNanoTDFECDSASignatureSize(
47
+ f"Invalid signature buffer size. Expected {(2 * key_size) + 2}, got {len(ecdsa_signature_value)}"
48
+ )
49
+
50
+ struct_obj = cls()
51
+
52
+ # Copy value of r_length to signature struct
53
+ index = 0
54
+ struct_obj.r_length[0] = ecdsa_signature_value[index]
55
+
56
+ # Copy the contents of r_value to signature struct
57
+ index += 1
58
+ r_len = struct_obj.r_length[0]
59
+ struct_obj.r_value = bytearray(key_size)
60
+ struct_obj.r_value[:r_len] = ecdsa_signature_value[index : index + r_len]
61
+
62
+ # Copy value of s_length to signature struct
63
+ index += key_size
64
+ struct_obj.s_length[0] = ecdsa_signature_value[index]
65
+
66
+ # Copy value of s_value
67
+ index += 1
68
+ s_len = struct_obj.s_length[0]
69
+ struct_obj.s_value = bytearray(key_size)
70
+ struct_obj.s_value[:s_len] = ecdsa_signature_value[index : index + s_len]
71
+
72
+ return struct_obj
73
+
74
+ def as_bytes(self) -> bytes:
75
+ """
76
+ Convert the signature structure to bytes.
77
+ Raises ValueError if r_value or s_value is None.
78
+ """
79
+ if self.r_value is None or self.s_value is None:
80
+ raise ValueError("r_value and s_value must not be None")
81
+ total_size = 1 + len(self.r_value) + 1 + len(self.s_value)
82
+ signature = bytearray(total_size)
83
+
84
+ # Copy value of r_length
85
+ index = 0
86
+ signature[index] = self.r_length[0]
87
+
88
+ # Copy the contents of r_value
89
+ index += 1
90
+ signature[index : index + len(self.r_value)] = self.r_value
91
+
92
+ # Copy value of s_length
93
+ index += len(self.r_value)
94
+ signature[index] = self.s_length[0]
95
+
96
+ # Copy value of s_value
97
+ index += 1
98
+ signature[index : index + len(self.s_value)] = self.s_value
99
+
100
+ return bytes(signature)
101
+
102
+ def get_s_value(self) -> bytearray:
103
+ """Get the s value of the signature."""
104
+ return self.s_value
105
+
106
+ def set_s_value(self, s_value: bytearray) -> None:
107
+ """Set the s value of the signature."""
108
+ self.s_value = s_value
109
+
110
+ def get_s_length(self) -> int:
111
+ """Get the length of the s value."""
112
+ return self.s_length[0]
113
+
114
+ def set_s_length(self, s_length: int) -> None:
115
+ """Set the length of the s value."""
116
+ self.s_length[0] = s_length
117
+
118
+ def get_r_value(self) -> bytearray:
119
+ """Get the r value of the signature."""
120
+ return self.r_value
121
+
122
+ def set_r_value(self, r_value: bytearray) -> None:
123
+ """Set the r value of the signature."""
124
+ self.r_value = r_value
125
+
126
+ def get_r_length(self) -> int:
127
+ """Get the length of the r value."""
128
+ return self.r_length[0]
129
+
130
+ def set_r_length(self, r_length: int) -> None:
131
+ """Set the length of the r value."""
132
+ self.r_length[0] = r_length