otdf-python 0.1.9__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.
- otdf_python/__init__.py +25 -0
- otdf_python/__main__.py +12 -0
- otdf_python/address_normalizer.py +84 -0
- otdf_python/aesgcm.py +55 -0
- otdf_python/assertion_config.py +84 -0
- otdf_python/asym_crypto.py +85 -0
- otdf_python/asym_decryption.py +53 -0
- otdf_python/asym_encryption.py +75 -0
- otdf_python/auth_headers.py +21 -0
- otdf_python/autoconfigure_utils.py +113 -0
- otdf_python/cli.py +570 -0
- otdf_python/collection_store.py +41 -0
- otdf_python/collection_store_impl.py +22 -0
- otdf_python/config.py +69 -0
- otdf_python/connect_client.py +0 -0
- otdf_python/constants.py +1 -0
- otdf_python/crypto_utils.py +78 -0
- otdf_python/dpop.py +81 -0
- otdf_python/ecc_mode.py +32 -0
- otdf_python/eckeypair.py +75 -0
- otdf_python/header.py +143 -0
- otdf_python/invalid_zip_exception.py +8 -0
- otdf_python/kas_client.py +603 -0
- otdf_python/kas_connect_rpc_client.py +207 -0
- otdf_python/kas_info.py +25 -0
- otdf_python/kas_key_cache.py +52 -0
- otdf_python/key_type.py +31 -0
- otdf_python/key_type_constants.py +43 -0
- otdf_python/manifest.py +215 -0
- otdf_python/nanotdf.py +553 -0
- otdf_python/nanotdf_ecdsa_struct.py +132 -0
- otdf_python/nanotdf_type.py +43 -0
- otdf_python/policy_binding_serializer.py +39 -0
- otdf_python/policy_info.py +78 -0
- otdf_python/policy_object.py +22 -0
- otdf_python/policy_stub.py +2 -0
- otdf_python/resource_locator.py +44 -0
- otdf_python/sdk.py +528 -0
- otdf_python/sdk_builder.py +448 -0
- otdf_python/sdk_exceptions.py +16 -0
- otdf_python/symmetric_and_payload_config.py +30 -0
- otdf_python/tdf.py +479 -0
- otdf_python/tdf_reader.py +153 -0
- otdf_python/tdf_writer.py +23 -0
- otdf_python/token_source.py +34 -0
- otdf_python/version.py +57 -0
- otdf_python/zip_reader.py +47 -0
- otdf_python/zip_writer.py +70 -0
- otdf_python-0.3.1.dist-info/METADATA +231 -0
- otdf_python-0.3.1.dist-info/RECORD +137 -0
- {otdf_python-0.1.9.dist-info → otdf_python-0.3.1.dist-info}/WHEEL +1 -2
- {otdf_python-0.1.9.dist-info → otdf_python-0.3.1.dist-info/licenses}/LICENSE +1 -1
- otdf_python_proto/__init__.py +37 -0
- otdf_python_proto/authorization/__init__.py +1 -0
- otdf_python_proto/authorization/authorization_pb2.py +80 -0
- otdf_python_proto/authorization/authorization_pb2.pyi +161 -0
- otdf_python_proto/authorization/authorization_pb2_connect.py +191 -0
- otdf_python_proto/authorization/v2/authorization_pb2.py +105 -0
- otdf_python_proto/authorization/v2/authorization_pb2.pyi +134 -0
- otdf_python_proto/authorization/v2/authorization_pb2_connect.py +233 -0
- otdf_python_proto/common/__init__.py +1 -0
- otdf_python_proto/common/common_pb2.py +52 -0
- otdf_python_proto/common/common_pb2.pyi +61 -0
- otdf_python_proto/entity/__init__.py +1 -0
- otdf_python_proto/entity/entity_pb2.py +47 -0
- otdf_python_proto/entity/entity_pb2.pyi +50 -0
- otdf_python_proto/entityresolution/__init__.py +1 -0
- otdf_python_proto/entityresolution/entity_resolution_pb2.py +57 -0
- otdf_python_proto/entityresolution/entity_resolution_pb2.pyi +55 -0
- otdf_python_proto/entityresolution/entity_resolution_pb2_connect.py +149 -0
- otdf_python_proto/entityresolution/v2/entity_resolution_pb2.py +55 -0
- otdf_python_proto/entityresolution/v2/entity_resolution_pb2.pyi +55 -0
- otdf_python_proto/entityresolution/v2/entity_resolution_pb2_connect.py +149 -0
- otdf_python_proto/kas/__init__.py +9 -0
- otdf_python_proto/kas/kas_pb2.py +103 -0
- otdf_python_proto/kas/kas_pb2.pyi +170 -0
- otdf_python_proto/kas/kas_pb2_connect.py +192 -0
- otdf_python_proto/legacy_grpc/__init__.py +1 -0
- otdf_python_proto/legacy_grpc/authorization/authorization_pb2_grpc.py +163 -0
- otdf_python_proto/legacy_grpc/authorization/v2/authorization_pb2_grpc.py +206 -0
- otdf_python_proto/legacy_grpc/common/common_pb2_grpc.py +4 -0
- otdf_python_proto/legacy_grpc/entity/entity_pb2_grpc.py +4 -0
- otdf_python_proto/legacy_grpc/entityresolution/entity_resolution_pb2_grpc.py +122 -0
- otdf_python_proto/legacy_grpc/entityresolution/v2/entity_resolution_pb2_grpc.py +120 -0
- otdf_python_proto/legacy_grpc/kas/kas_pb2_grpc.py +172 -0
- otdf_python_proto/legacy_grpc/logger/audit/test_pb2_grpc.py +4 -0
- otdf_python_proto/legacy_grpc/policy/actions/actions_pb2_grpc.py +249 -0
- otdf_python_proto/legacy_grpc/policy/attributes/attributes_pb2_grpc.py +873 -0
- otdf_python_proto/legacy_grpc/policy/kasregistry/key_access_server_registry_pb2_grpc.py +602 -0
- otdf_python_proto/legacy_grpc/policy/keymanagement/key_management_pb2_grpc.py +251 -0
- otdf_python_proto/legacy_grpc/policy/namespaces/namespaces_pb2_grpc.py +427 -0
- otdf_python_proto/legacy_grpc/policy/objects_pb2_grpc.py +4 -0
- otdf_python_proto/legacy_grpc/policy/registeredresources/registered_resources_pb2_grpc.py +524 -0
- otdf_python_proto/legacy_grpc/policy/resourcemapping/resource_mapping_pb2_grpc.py +516 -0
- otdf_python_proto/legacy_grpc/policy/selectors_pb2_grpc.py +4 -0
- otdf_python_proto/legacy_grpc/policy/subjectmapping/subject_mapping_pb2_grpc.py +551 -0
- otdf_python_proto/legacy_grpc/policy/unsafe/unsafe_pb2_grpc.py +485 -0
- otdf_python_proto/legacy_grpc/wellknownconfiguration/wellknown_configuration_pb2_grpc.py +77 -0
- otdf_python_proto/logger/__init__.py +1 -0
- otdf_python_proto/logger/audit/test_pb2.py +43 -0
- otdf_python_proto/logger/audit/test_pb2.pyi +45 -0
- otdf_python_proto/policy/__init__.py +1 -0
- otdf_python_proto/policy/actions/actions_pb2.py +75 -0
- otdf_python_proto/policy/actions/actions_pb2.pyi +87 -0
- otdf_python_proto/policy/actions/actions_pb2_connect.py +275 -0
- otdf_python_proto/policy/attributes/attributes_pb2.py +234 -0
- otdf_python_proto/policy/attributes/attributes_pb2.pyi +328 -0
- otdf_python_proto/policy/attributes/attributes_pb2_connect.py +863 -0
- otdf_python_proto/policy/kasregistry/key_access_server_registry_pb2.py +266 -0
- otdf_python_proto/policy/kasregistry/key_access_server_registry_pb2.pyi +450 -0
- otdf_python_proto/policy/kasregistry/key_access_server_registry_pb2_connect.py +611 -0
- otdf_python_proto/policy/keymanagement/key_management_pb2.py +79 -0
- otdf_python_proto/policy/keymanagement/key_management_pb2.pyi +87 -0
- otdf_python_proto/policy/keymanagement/key_management_pb2_connect.py +275 -0
- otdf_python_proto/policy/namespaces/namespaces_pb2.py +117 -0
- otdf_python_proto/policy/namespaces/namespaces_pb2.pyi +147 -0
- otdf_python_proto/policy/namespaces/namespaces_pb2_connect.py +443 -0
- otdf_python_proto/policy/objects_pb2.py +150 -0
- otdf_python_proto/policy/objects_pb2.pyi +464 -0
- otdf_python_proto/policy/registeredresources/registered_resources_pb2.py +139 -0
- otdf_python_proto/policy/registeredresources/registered_resources_pb2.pyi +196 -0
- otdf_python_proto/policy/registeredresources/registered_resources_pb2_connect.py +527 -0
- otdf_python_proto/policy/resourcemapping/resource_mapping_pb2.py +139 -0
- otdf_python_proto/policy/resourcemapping/resource_mapping_pb2.pyi +194 -0
- otdf_python_proto/policy/resourcemapping/resource_mapping_pb2_connect.py +527 -0
- otdf_python_proto/policy/selectors_pb2.py +57 -0
- otdf_python_proto/policy/selectors_pb2.pyi +90 -0
- otdf_python_proto/policy/subjectmapping/subject_mapping_pb2.py +127 -0
- otdf_python_proto/policy/subjectmapping/subject_mapping_pb2.pyi +189 -0
- otdf_python_proto/policy/subjectmapping/subject_mapping_pb2_connect.py +569 -0
- otdf_python_proto/policy/unsafe/unsafe_pb2.py +113 -0
- otdf_python_proto/policy/unsafe/unsafe_pb2.pyi +145 -0
- otdf_python_proto/policy/unsafe/unsafe_pb2_connect.py +485 -0
- otdf_python_proto/wellknownconfiguration/__init__.py +1 -0
- otdf_python_proto/wellknownconfiguration/wellknown_configuration_pb2.py +51 -0
- otdf_python_proto/wellknownconfiguration/wellknown_configuration_pb2.pyi +32 -0
- otdf_python_proto/wellknownconfiguration/wellknown_configuration_pb2_connect.py +107 -0
- otdf_python/_gotdf_python.cpython-312-darwin.so +0 -0
- otdf_python/build.py +0 -190
- otdf_python/go.py +0 -1478
- otdf_python/gotdf_python.py +0 -383
- otdf_python-0.1.9.dist-info/METADATA +0 -149
- otdf_python-0.1.9.dist-info/RECORD +0 -10
- otdf_python-0.1.9.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
|