otdf-python 0.3.4__py3-none-any.whl → 0.4.0__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/asym_crypto.py +135 -22
- otdf_python/cli.py +8 -1
- otdf_python/ecc_constants.py +176 -0
- otdf_python/ecc_mode.py +60 -9
- otdf_python/ecdh.py +317 -0
- otdf_python/header.py +38 -0
- otdf_python/kas_client.py +172 -66
- otdf_python/nanotdf.py +445 -135
- otdf_python/policy_info.py +5 -28
- otdf_python/resource_locator.py +149 -21
- otdf_python/sdk.py +1 -1
- otdf_python/tdf.py +4 -3
- {otdf_python-0.3.4.dist-info → otdf_python-0.4.0.dist-info}/METADATA +19 -2
- {otdf_python-0.3.4.dist-info → otdf_python-0.4.0.dist-info}/RECORD +16 -16
- otdf_python/asym_decryption.py +0 -53
- otdf_python/asym_encryption.py +0 -75
- {otdf_python-0.3.4.dist-info → otdf_python-0.4.0.dist-info}/WHEEL +0 -0
- {otdf_python-0.3.4.dist-info → otdf_python-0.4.0.dist-info}/licenses/LICENSE +0 -0
otdf_python/nanotdf.py
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
+
import contextlib
|
|
1
2
|
import hashlib
|
|
2
3
|
import json
|
|
3
4
|
import secrets
|
|
4
5
|
from io import BytesIO
|
|
5
6
|
from typing import BinaryIO
|
|
6
7
|
|
|
8
|
+
from cryptography.hazmat.primitives import serialization
|
|
9
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
7
10
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
8
11
|
|
|
9
|
-
from otdf_python.asym_crypto import AsymDecryption
|
|
10
12
|
from otdf_python.collection_store import CollectionStore, NoOpCollectionStore
|
|
11
13
|
from otdf_python.config import KASInfo, NanoTDFConfig
|
|
12
14
|
from otdf_python.constants import MAGIC_NUMBER_AND_VERSION
|
|
@@ -18,6 +20,8 @@ from otdf_python.resource_locator import ResourceLocator
|
|
|
18
20
|
from otdf_python.sdk_exceptions import SDKException
|
|
19
21
|
from otdf_python.symmetric_and_payload_config import SymmetricAndPayloadConfig
|
|
20
22
|
|
|
23
|
+
from .asym_crypto import AsymDecryption
|
|
24
|
+
|
|
21
25
|
|
|
22
26
|
class NanoTDFException(SDKException):
|
|
23
27
|
pass
|
|
@@ -140,7 +144,11 @@ class NanoTDF:
|
|
|
140
144
|
return key
|
|
141
145
|
|
|
142
146
|
def _create_header(
|
|
143
|
-
self,
|
|
147
|
+
self,
|
|
148
|
+
policy_body: bytes,
|
|
149
|
+
policy_type: str,
|
|
150
|
+
config: NanoTDFConfig,
|
|
151
|
+
ephemeral_public_key: bytes | None = None,
|
|
144
152
|
) -> bytes:
|
|
145
153
|
"""
|
|
146
154
|
Create the NanoTDF header.
|
|
@@ -149,6 +157,7 @@ class NanoTDF:
|
|
|
149
157
|
policy_body: The policy body bytes
|
|
150
158
|
policy_type: The policy type string
|
|
151
159
|
config: NanoTDFConfig configuration
|
|
160
|
+
ephemeral_public_key: Optional compressed ephemeral public key (from ECDH)
|
|
152
161
|
|
|
153
162
|
Returns:
|
|
154
163
|
bytes: The header bytes
|
|
@@ -160,7 +169,12 @@ class NanoTDF:
|
|
|
160
169
|
if config.kas_info_list and len(config.kas_info_list) > 0:
|
|
161
170
|
kas_url = config.kas_info_list[0].url
|
|
162
171
|
|
|
163
|
-
|
|
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
|
+
|
|
164
178
|
kas_locator = ResourceLocator(kas_url, kas_id)
|
|
165
179
|
|
|
166
180
|
# Get ECC mode from config or use default
|
|
@@ -172,7 +186,9 @@ class NanoTDF:
|
|
|
172
186
|
ecc_mode = config.ecc_mode
|
|
173
187
|
|
|
174
188
|
# Default payload config
|
|
175
|
-
|
|
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)
|
|
176
192
|
|
|
177
193
|
# Create policy info
|
|
178
194
|
policy_info = PolicyInfo()
|
|
@@ -180,9 +196,11 @@ class NanoTDF:
|
|
|
180
196
|
policy_info.set_embedded_plain_text_policy(policy_body)
|
|
181
197
|
else:
|
|
182
198
|
policy_info.set_embedded_encrypted_text_policy(policy_body)
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
)
|
|
199
|
+
|
|
200
|
+
# Create policy binding (GMAC)
|
|
201
|
+
policy_binding = hashlib.sha256(policy_body).digest()[
|
|
202
|
+
-self.K_NANOTDF_GMAC_LENGTH :
|
|
203
|
+
]
|
|
186
204
|
|
|
187
205
|
# Build the header
|
|
188
206
|
header = Header()
|
|
@@ -190,59 +208,180 @@ class NanoTDF:
|
|
|
190
208
|
header.set_ecc_mode(ecc_mode)
|
|
191
209
|
header.set_payload_config(payload_config)
|
|
192
210
|
header.set_policy_info(policy_info)
|
|
193
|
-
header.
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
+
)
|
|
197
223
|
)
|
|
198
224
|
)
|
|
199
|
-
)
|
|
200
225
|
|
|
201
226
|
# Generate and return the header bytes with magic number
|
|
202
227
|
header_bytes = header.to_bytes()
|
|
203
228
|
return self.MAGIC_NUMBER_AND_VERSION + header_bytes
|
|
204
229
|
|
|
205
|
-
def
|
|
206
|
-
self, key: bytes, config: NanoTDFConfig
|
|
207
|
-
) -> tuple[bytes, bytes | None]:
|
|
230
|
+
def _is_ec_key(self, key_pem: str) -> bool:
|
|
208
231
|
"""
|
|
209
|
-
|
|
232
|
+
Detect if a PEM key is an EC key (vs RSA).
|
|
210
233
|
|
|
211
234
|
Args:
|
|
212
|
-
|
|
213
|
-
config: NanoTDFConfig with potential KASInfo
|
|
235
|
+
key_pem: PEM-formatted key string
|
|
214
236
|
|
|
215
237
|
Returns:
|
|
216
|
-
|
|
238
|
+
bool: True if EC key, False if RSA key
|
|
239
|
+
|
|
240
|
+
Raises:
|
|
241
|
+
SDKException: If key cannot be parsed
|
|
217
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
|
+
|
|
218
291
|
kas_public_key = None
|
|
219
|
-
|
|
292
|
+
derived_key = None
|
|
293
|
+
ephemeral_public_key_compressed = None
|
|
220
294
|
|
|
221
295
|
if config.kas_info_list and len(config.kas_info_list) > 0:
|
|
222
|
-
# Get the first KASInfo with a public_key
|
|
296
|
+
# Get the first KASInfo with a public_key or fetch it
|
|
223
297
|
for kas_info in config.kas_info_list:
|
|
224
298
|
if kas_info.public_key:
|
|
225
299
|
kas_public_key = kas_info.public_key
|
|
226
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
|
|
227
326
|
|
|
228
327
|
if kas_public_key:
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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"
|
|
243
382
|
)
|
|
244
383
|
|
|
245
|
-
return
|
|
384
|
+
return derived_key, ephemeral_public_key_compressed, kas_public_key
|
|
246
385
|
|
|
247
386
|
def _encrypt_payload(self, payload: bytes, key: bytes) -> tuple[bytes, bytes]:
|
|
248
387
|
"""
|
|
@@ -265,8 +404,10 @@ class NanoTDF:
|
|
|
265
404
|
self, payload: bytes | BytesIO, output_stream: BinaryIO, config: NanoTDFConfig
|
|
266
405
|
) -> int:
|
|
267
406
|
"""
|
|
268
|
-
|
|
269
|
-
|
|
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.
|
|
270
411
|
|
|
271
412
|
Args:
|
|
272
413
|
payload: The payload data as bytes or BytesIO
|
|
@@ -289,46 +430,143 @@ class NanoTDF:
|
|
|
289
430
|
# Process policy data
|
|
290
431
|
policy_body, policy_type = self._prepare_policy_data(config)
|
|
291
432
|
|
|
292
|
-
#
|
|
293
|
-
|
|
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)
|
|
294
443
|
|
|
295
|
-
# Create header
|
|
296
|
-
header_bytes = self._create_header(
|
|
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
|
+
)
|
|
297
448
|
output_stream.write(header_bytes)
|
|
298
449
|
|
|
299
450
|
# Encrypt payload
|
|
300
|
-
iv,
|
|
451
|
+
iv, ciphertext_with_tag = self._encrypt_payload(payload, key)
|
|
301
452
|
|
|
302
|
-
#
|
|
303
|
-
|
|
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)
|
|
304
458
|
|
|
305
|
-
#
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
|
309
499
|
)
|
|
310
|
-
else:
|
|
311
|
-
nano_tdf_data = iv + ciphertext + (0).to_bytes(2, "big")
|
|
312
500
|
|
|
313
|
-
|
|
314
|
-
|
|
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
|
|
315
531
|
|
|
316
|
-
|
|
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
|
|
317
555
|
self,
|
|
318
556
|
nano_tdf_data: bytes | BytesIO,
|
|
319
557
|
output_stream: BinaryIO,
|
|
320
558
|
config: NanoTDFConfig,
|
|
321
|
-
platform_url: str | None = None,
|
|
322
559
|
) -> None:
|
|
323
560
|
"""
|
|
324
|
-
|
|
325
|
-
|
|
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.
|
|
326
565
|
|
|
327
566
|
Args:
|
|
328
567
|
nano_tdf_data: The NanoTDF data as bytes or BytesIO
|
|
329
568
|
output_stream: The output stream to write the payload to
|
|
330
569
|
config: Configuration for the NanoTDF reader
|
|
331
|
-
platform_url: Optional platform URL for KAS resolution
|
|
332
570
|
|
|
333
571
|
Raises:
|
|
334
572
|
InvalidNanoTDFConfig: If the NanoTDF format is invalid or config is missing required info
|
|
@@ -342,58 +580,148 @@ class NanoTDF:
|
|
|
342
580
|
|
|
343
581
|
try:
|
|
344
582
|
header_len = Header.peek_length(nano_tdf_data)
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
#
|
|
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)
|
|
350
601
|
iv = payload[0:3]
|
|
351
602
|
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
603
|
|
|
357
|
-
|
|
358
|
-
|
|
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
|
|
604
|
+
# The rest is ciphertext + tag
|
|
605
|
+
ciphertext_with_tag = payload[3:]
|
|
366
606
|
|
|
367
|
-
|
|
368
|
-
kas_mock_unwrap = False
|
|
369
|
-
if config.config and "mock_unwrap=true" in config.config.lower():
|
|
370
|
-
kas_mock_unwrap = True
|
|
607
|
+
key = None
|
|
371
608
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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()
|
|
397
725
|
output_stream.write(plaintext)
|
|
398
726
|
|
|
399
727
|
def _convert_dict_to_nanotdf_config(self, config: dict) -> NanoTDFConfig:
|
|
@@ -440,7 +768,11 @@ class NanoTDF:
|
|
|
440
768
|
return key, config
|
|
441
769
|
|
|
442
770
|
def create_nanotdf(self, data: bytes, config: dict | NanoTDFConfig) -> bytes:
|
|
443
|
-
"""
|
|
771
|
+
"""
|
|
772
|
+
Convenience method - creates a NanoTDF and returns the encrypted bytes.
|
|
773
|
+
|
|
774
|
+
For stream-based version, use create_nano_tdf() instead.
|
|
775
|
+
"""
|
|
444
776
|
if len(data) > self.K_MAX_TDF_SIZE:
|
|
445
777
|
raise NanoTDFMaxSizeLimit("exceeds max size for nano tdf")
|
|
446
778
|
|
|
@@ -514,40 +846,18 @@ class NanoTDF:
|
|
|
514
846
|
def read_nanotdf(
|
|
515
847
|
self, nanotdf_bytes: bytes, config: dict | NanoTDFConfig | None = None
|
|
516
848
|
) -> bytes:
|
|
517
|
-
"""
|
|
849
|
+
"""
|
|
850
|
+
Convenience method - decrypts a NanoTDF and returns the plaintext bytes.
|
|
851
|
+
|
|
852
|
+
For stream-based version, use read_nano_tdf() instead.
|
|
853
|
+
"""
|
|
518
854
|
output = BytesIO()
|
|
519
|
-
from otdf_python.header import Header # Local import to avoid circular import
|
|
520
855
|
|
|
521
856
|
# Convert config to NanoTDFConfig if it's a dict
|
|
522
857
|
if isinstance(config, dict):
|
|
523
858
|
config = self._convert_dict_to_read_config(config)
|
|
524
859
|
|
|
525
|
-
|
|
526
|
-
|
|
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}")
|
|
860
|
+
# Use the stream-based method internally
|
|
861
|
+
self.read_nano_tdf(nanotdf_bytes, output, config)
|
|
552
862
|
|
|
553
863
|
return output.getvalue()
|