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.
- 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 +198 -0
- otdf_python/auth_headers.py +33 -0
- otdf_python/autoconfigure_utils.py +113 -0
- otdf_python/cli.py +569 -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_constants.py +176 -0
- otdf_python/ecc_mode.py +83 -0
- otdf_python/ecdh.py +317 -0
- otdf_python/eckeypair.py +75 -0
- otdf_python/header.py +181 -0
- otdf_python/invalid_zip_exception.py +8 -0
- otdf_python/kas_client.py +709 -0
- otdf_python/kas_connect_rpc_client.py +213 -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 +863 -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 +55 -0
- otdf_python/policy_object.py +22 -0
- otdf_python/policy_stub.py +2 -0
- otdf_python/resource_locator.py +172 -0
- otdf_python/sdk.py +436 -0
- otdf_python/sdk_builder.py +416 -0
- otdf_python/sdk_exceptions.py +16 -0
- otdf_python/symmetric_and_payload_config.py +30 -0
- otdf_python/tdf.py +480 -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.5.dist-info/METADATA +153 -0
- otdf_python-0.3.5.dist-info/RECORD +137 -0
- {otdf_python-0.1.10.dist-info → otdf_python-0.3.5.dist-info}/WHEEL +1 -2
- {otdf_python-0.1.10.dist-info → otdf_python-0.3.5.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.10.dist-info/METADATA +0 -149
- otdf_python-0.1.10.dist-info/RECORD +0 -10
- 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()
|