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/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, policy_body: bytes, policy_type: str, config: NanoTDFConfig
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
- kas_id = "kas-id" # Default KAS ID
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
- payload_config = SymmetricAndPayloadConfig(0, 0, False)
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
- policy_info.set_policy_binding(
184
- hashlib.sha256(policy_body).digest()[-self.K_NANOTDF_GMAC_LENGTH :]
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.set_ephemeral_key(
194
- secrets.token_bytes(
195
- ECCMode.get_ec_compressed_pubkey_size(
196
- ecc_mode.get_elliptic_curve_type()
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 _wrap_key_if_needed(
206
- self, key: bytes, config: NanoTDFConfig
207
- ) -> tuple[bytes, bytes | None]:
230
+ def _is_ec_key(self, key_pem: str) -> bool:
208
231
  """
209
- Wrap encryption key if KAS public key is provided.
232
+ Detect if a PEM key is an EC key (vs RSA).
210
233
 
211
234
  Args:
212
- key: The encryption key
213
- config: NanoTDFConfig with potential KASInfo
235
+ key_pem: PEM-formatted key string
214
236
 
215
237
  Returns:
216
- tuple: (wrapped_key, kas_public_key)
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
- wrapped_key = None
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
- 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
- ),
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 wrapped_key, kas_public_key
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
- 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.
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
- # Get or generate encryption key
293
- key = self._prepare_encryption_key(config)
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 and write to output
296
- header_bytes = self._create_header(policy_body, policy_type, config)
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, ciphertext = self._encrypt_payload(payload, key)
451
+ iv, ciphertext_with_tag = self._encrypt_payload(payload, key)
301
452
 
302
- # Wrap key if needed
303
- wrapped_key, _kas_public_key = self._wrap_key_if_needed(key, config)
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
- # 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")
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
- output_stream.write(nano_tdf_data)
314
- return len(header_bytes) + len(nano_tdf_data)
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
- def read_nano_tdf(
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
- Reads a NanoTDF and writes the payload to the output stream.
325
- Supports KAS key unwrapping if kas_private_key is provided in config.
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
- 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
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
- # 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
604
+ # The rest is ciphertext + tag
605
+ ciphertext_with_tag = payload[3:]
366
606
 
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
607
+ key = None
371
608
 
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)
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
- """Create a NanoTDF from input data using the provided configuration."""
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
- """Read and decrypt a NanoTDF, returning the original plaintext data."""
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
- 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}")
860
+ # Use the stream-based method internally
861
+ self.read_nano_tdf(nanotdf_bytes, output, config)
552
862
 
553
863
  return output.getvalue()