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/ecdh.py ADDED
@@ -0,0 +1,317 @@
1
+ """
2
+ ECDH (Elliptic Curve Diffie-Hellman) key exchange for NanoTDF.
3
+
4
+ This module implements the ECDH key exchange protocol with HKDF key derivation
5
+ as specified in the NanoTDF spec. It supports the following curves:
6
+ - secp256r1 (NIST P-256)
7
+ - secp384r1 (NIST P-384)
8
+ - secp521r1 (NIST P-521)
9
+ - secp256k1 (Bitcoin curve)
10
+
11
+ The protocol follows ECIES methodology similar to S/MIME and GPG:
12
+ 1. Generate ephemeral keypair
13
+ 2. Perform ECDH with recipient's public key to get shared secret
14
+ 3. Use HKDF to derive symmetric encryption key from shared secret
15
+ """
16
+
17
+ from cryptography.hazmat.backends import default_backend
18
+ from cryptography.hazmat.primitives import hashes, serialization
19
+ from cryptography.hazmat.primitives.asymmetric import ec
20
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
21
+ from cryptography.hazmat.primitives.serialization import (
22
+ Encoding,
23
+ PublicFormat,
24
+ )
25
+
26
+ from otdf_python.ecc_constants import ECCConstants
27
+
28
+ # HKDF salt for NanoTDF key derivation
29
+ # Per spec: "salt value derived from magic number/version"
30
+ # This is the SHA-256 hash of the NanoTDF magic number and version
31
+ NANOTDF_HKDF_SALT = bytes.fromhex(
32
+ "3de3ca1e50cf62d8b6aba603a96fca6761387a7ac86c3d3afe85ae2d1812edfc"
33
+ )
34
+
35
+
36
+ class ECDHError(Exception):
37
+ """Base exception for ECDH operations."""
38
+
39
+ pass
40
+
41
+
42
+ class UnsupportedCurveError(ECDHError):
43
+ """Raised when an unsupported curve is specified."""
44
+
45
+ pass
46
+
47
+
48
+ class InvalidKeyError(ECDHError):
49
+ """Raised when a key is invalid or malformed."""
50
+
51
+ pass
52
+
53
+
54
+ def get_curve(curve_name: str) -> ec.EllipticCurve:
55
+ """
56
+ Get the cryptography curve object for a given curve name.
57
+
58
+ Args:
59
+ curve_name: Name of the curve (e.g., "secp256r1")
60
+
61
+ Returns:
62
+ ec.EllipticCurve: The curve object
63
+
64
+ Raises:
65
+ UnsupportedCurveError: If the curve is not supported
66
+ """
67
+ try:
68
+ # Delegate to ECCConstants for the authoritative mapping
69
+ return ECCConstants.get_curve_object(curve_name)
70
+ except ValueError as e:
71
+ raise UnsupportedCurveError(str(e)) from e
72
+
73
+
74
+ def get_compressed_key_size(curve_name: str) -> int:
75
+ """
76
+ Get the size of a compressed public key for a given curve.
77
+
78
+ Args:
79
+ curve_name: Name of the curve (e.g., "secp256r1")
80
+
81
+ Returns:
82
+ int: Size in bytes of the compressed public key
83
+
84
+ Raises:
85
+ UnsupportedCurveError: If the curve is not supported
86
+ """
87
+ try:
88
+ # Delegate to ECCConstants for the authoritative mapping
89
+ return ECCConstants.get_compressed_key_size_by_name(curve_name)
90
+ except ValueError as e:
91
+ raise UnsupportedCurveError(str(e)) from e
92
+
93
+
94
+ def generate_ephemeral_keypair(
95
+ curve_name: str,
96
+ ) -> tuple[ec.EllipticCurvePrivateKey, ec.EllipticCurvePublicKey]:
97
+ """
98
+ Generate an ephemeral keypair for ECDH.
99
+
100
+ Args:
101
+ curve_name: Name of the curve (e.g., "secp256r1")
102
+
103
+ Returns:
104
+ tuple: (private_key, public_key)
105
+
106
+ Raises:
107
+ UnsupportedCurveError: If the curve is not supported
108
+ """
109
+ curve = get_curve(curve_name)
110
+ private_key = ec.generate_private_key(curve, default_backend())
111
+ public_key = private_key.public_key()
112
+ return private_key, public_key
113
+
114
+
115
+ def compress_public_key(public_key: ec.EllipticCurvePublicKey) -> bytes:
116
+ """
117
+ Compress an EC public key to compressed point format.
118
+
119
+ Args:
120
+ public_key: The EC public key to compress
121
+
122
+ Returns:
123
+ bytes: Compressed public key (33-67 bytes depending on curve)
124
+ """
125
+ return public_key.public_bytes(
126
+ encoding=Encoding.X962, format=PublicFormat.CompressedPoint
127
+ )
128
+
129
+
130
+ def decompress_public_key(
131
+ compressed_key: bytes, curve_name: str
132
+ ) -> ec.EllipticCurvePublicKey:
133
+ """
134
+ Decompress a public key from compressed point format.
135
+
136
+ Args:
137
+ compressed_key: The compressed public key bytes
138
+ curve_name: Name of the curve (e.g., "secp256r1")
139
+
140
+ Returns:
141
+ ec.EllipticCurvePublicKey: The decompressed public key
142
+
143
+ Raises:
144
+ InvalidKeyError: If the key cannot be decompressed
145
+ UnsupportedCurveError: If the curve is not supported
146
+ """
147
+ try:
148
+ curve = get_curve(curve_name)
149
+ # Verify the size matches expected compressed size
150
+ expected_size = get_compressed_key_size(curve_name)
151
+ if len(compressed_key) != expected_size:
152
+ raise InvalidKeyError(
153
+ f"Invalid compressed key size for {curve_name}: "
154
+ f"expected {expected_size} bytes, got {len(compressed_key)} bytes"
155
+ )
156
+
157
+ return ec.EllipticCurvePublicKey.from_encoded_point(curve, compressed_key)
158
+ except (ValueError, TypeError) as e:
159
+ raise InvalidKeyError(f"Failed to decompress public key: {e}")
160
+
161
+
162
+ def derive_shared_secret(
163
+ private_key: ec.EllipticCurvePrivateKey, public_key: ec.EllipticCurvePublicKey
164
+ ) -> bytes:
165
+ """
166
+ Derive a shared secret using ECDH.
167
+
168
+ Args:
169
+ private_key: The private key (can be ephemeral or recipient's key)
170
+ public_key: The public key (recipient's or ephemeral key)
171
+
172
+ Returns:
173
+ bytes: The raw shared secret (x-coordinate of the ECDH point)
174
+
175
+ Raises:
176
+ ECDHError: If ECDH fails
177
+ """
178
+ try:
179
+ shared_secret = private_key.exchange(ec.ECDH(), public_key)
180
+ return shared_secret
181
+ except Exception as e:
182
+ raise ECDHError(f"Failed to derive shared secret: {e}")
183
+
184
+
185
+ def derive_key_from_shared_secret(
186
+ shared_secret: bytes,
187
+ key_length: int = 32,
188
+ salt: bytes | None = None,
189
+ info: bytes = b"",
190
+ ) -> bytes:
191
+ """
192
+ Derive a symmetric encryption key from the ECDH shared secret using HKDF.
193
+
194
+ Args:
195
+ shared_secret: The raw ECDH shared secret
196
+ key_length: Length of the derived key in bytes (default: 32 for AES-256)
197
+ salt: Optional salt for HKDF (default: NANOTDF_HKDF_SALT)
198
+ info: Optional context/application-specific info (default: empty)
199
+
200
+ Returns:
201
+ bytes: Derived symmetric encryption key
202
+
203
+ Raises:
204
+ ECDHError: If key derivation fails
205
+ """
206
+ if salt is None:
207
+ salt = NANOTDF_HKDF_SALT
208
+
209
+ try:
210
+ hkdf = HKDF(
211
+ algorithm=hashes.SHA256(),
212
+ length=key_length,
213
+ salt=salt,
214
+ info=info,
215
+ backend=default_backend(),
216
+ )
217
+ return hkdf.derive(shared_secret)
218
+ except Exception as e:
219
+ raise ECDHError(f"Failed to derive key from shared secret: {e}")
220
+
221
+
222
+ def encrypt_key_with_ecdh(
223
+ recipient_public_key_pem: str, curve_name: str = "secp256r1"
224
+ ) -> tuple[bytes, bytes]:
225
+ """
226
+ High-level function: Generate ephemeral keypair and derive encryption key.
227
+
228
+ This is used during NanoTDF encryption to derive the key that will be used
229
+ to encrypt the payload. The ephemeral public key must be stored in the
230
+ NanoTDF header so the recipient can derive the same key.
231
+
232
+ Args:
233
+ recipient_public_key_pem: Recipient's public key in PEM format (e.g., KAS public key)
234
+ curve_name: Name of the curve to use (default: "secp256r1")
235
+
236
+ Returns:
237
+ tuple: (derived_key, compressed_ephemeral_public_key)
238
+ - derived_key: 32-byte AES-256 key for encrypting the payload
239
+ - compressed_ephemeral_public_key: Ephemeral public key to store in header
240
+
241
+ Raises:
242
+ ECDHError: If key derivation fails
243
+ InvalidKeyError: If recipient's public key is invalid
244
+ UnsupportedCurveError: If the curve is not supported
245
+ """
246
+ # Load recipient's public key
247
+ try:
248
+ recipient_public_key = serialization.load_pem_public_key(
249
+ recipient_public_key_pem.encode(), backend=default_backend()
250
+ )
251
+ if not isinstance(recipient_public_key, ec.EllipticCurvePublicKey):
252
+ raise InvalidKeyError("Recipient's public key is not an EC key")
253
+ except Exception as e:
254
+ raise InvalidKeyError(f"Failed to load recipient's public key: {e}")
255
+
256
+ # Generate ephemeral keypair
257
+ ephemeral_private_key, ephemeral_public_key = generate_ephemeral_keypair(curve_name)
258
+
259
+ # Derive shared secret
260
+ shared_secret = derive_shared_secret(ephemeral_private_key, recipient_public_key)
261
+
262
+ # Derive encryption key from shared secret
263
+ derived_key = derive_key_from_shared_secret(shared_secret, key_length=32)
264
+
265
+ # Compress ephemeral public key for storage in header
266
+ compressed_ephemeral_key = compress_public_key(ephemeral_public_key)
267
+
268
+ return derived_key, compressed_ephemeral_key
269
+
270
+
271
+ def decrypt_key_with_ecdh(
272
+ recipient_private_key_pem: str,
273
+ compressed_ephemeral_public_key: bytes,
274
+ curve_name: str = "secp256r1",
275
+ ) -> bytes:
276
+ """
277
+ High-level function: Derive decryption key from ephemeral public key and recipient's private key.
278
+
279
+ This is used during NanoTDF decryption to derive the same key that was used
280
+ to encrypt the payload. The ephemeral public key is extracted from the
281
+ NanoTDF header.
282
+
283
+ Args:
284
+ recipient_private_key_pem: Recipient's private key in PEM format (e.g., KAS private key)
285
+ compressed_ephemeral_public_key: Ephemeral public key from NanoTDF header
286
+ curve_name: Name of the curve (default: "secp256r1")
287
+
288
+ Returns:
289
+ bytes: 32-byte AES-256 key for decrypting the payload
290
+
291
+ Raises:
292
+ ECDHError: If key derivation fails
293
+ InvalidKeyError: If keys are invalid
294
+ UnsupportedCurveError: If the curve is not supported
295
+ """
296
+ # Load recipient's private key
297
+ try:
298
+ recipient_private_key = serialization.load_pem_private_key(
299
+ recipient_private_key_pem.encode(), password=None, backend=default_backend()
300
+ )
301
+ if not isinstance(recipient_private_key, ec.EllipticCurvePrivateKey):
302
+ raise InvalidKeyError("Recipient's private key is not an EC key")
303
+ except Exception as e:
304
+ raise InvalidKeyError(f"Failed to load recipient's private key: {e}")
305
+
306
+ # Decompress ephemeral public key
307
+ ephemeral_public_key = decompress_public_key(
308
+ compressed_ephemeral_public_key, curve_name
309
+ )
310
+
311
+ # Derive shared secret
312
+ shared_secret = derive_shared_secret(recipient_private_key, ephemeral_public_key)
313
+
314
+ # Derive decryption key from shared secret
315
+ derived_key = derive_key_from_shared_secret(shared_secret, key_length=32)
316
+
317
+ return derived_key
otdf_python/header.py CHANGED
@@ -6,11 +6,15 @@ from otdf_python.symmetric_and_payload_config import SymmetricAndPayloadConfig
6
6
 
7
7
 
8
8
  class Header:
9
+ # Size of GMAC (Galois Message Authentication Code) for policy binding
10
+ GMAC_SIZE = 8
11
+
9
12
  def __init__(self):
10
13
  self.kas_locator: ResourceLocator | None = None
11
14
  self.ecc_mode: ECCMode | None = None
12
15
  self.payload_config: SymmetricAndPayloadConfig | None = None
13
16
  self.policy_info: PolicyInfo | None = None
17
+ self.policy_binding: bytes | None = None
14
18
  self.ephemeral_key: bytes | None = None
15
19
 
16
20
  @classmethod
@@ -31,6 +35,14 @@ class Header:
31
35
  buffer[offset:], ecc_mode
32
36
  )
33
37
  offset += policy_size
38
+
39
+ # Read policy binding (GMAC - 8 bytes fixed size)
40
+ # Note: ECDSA binding not yet supported in this implementation
41
+ policy_binding = buffer[offset : offset + cls.GMAC_SIZE]
42
+ if len(policy_binding) != cls.GMAC_SIZE:
43
+ raise ValueError("Failed to read policy binding - invalid buffer size.")
44
+ offset += cls.GMAC_SIZE
45
+
34
46
  compressed_pubkey_size = ECCMode.get_ec_compressed_pubkey_size(
35
47
  ecc_mode.get_elliptic_curve_type()
36
48
  )
@@ -42,6 +54,7 @@ class Header:
42
54
  obj.ecc_mode = ecc_mode
43
55
  obj.payload_config = payload_config
44
56
  obj.policy_info = policy_info
57
+ obj.policy_binding = policy_binding
45
58
  obj.ephemeral_key = ephemeral_key
46
59
  return obj
47
60
 
@@ -63,6 +76,8 @@ class Header:
63
76
  buffer[offset:], ecc_mode
64
77
  )
65
78
  offset += policy_size
79
+ # Policy binding (GMAC - 8 bytes)
80
+ offset += Header.GMAC_SIZE
66
81
  # Ephemeral key (size depends on curve)
67
82
  compressed_pubkey_size = ECCMode.get_ec_compressed_pubkey_size(
68
83
  ecc_mode.get_elliptic_curve_type()
@@ -94,6 +109,16 @@ class Header:
94
109
  def get_policy_info(self) -> PolicyInfo | None:
95
110
  return self.policy_info
96
111
 
112
+ def set_policy_binding(self, policy_binding: bytes):
113
+ if len(policy_binding) != self.GMAC_SIZE:
114
+ raise ValueError(
115
+ f"Policy binding must be exactly {self.GMAC_SIZE} bytes (GMAC), got {len(policy_binding)}"
116
+ )
117
+ self.policy_binding = policy_binding
118
+
119
+ def get_policy_binding(self) -> bytes | None:
120
+ return self.policy_binding
121
+
97
122
  def set_ephemeral_key(self, ephemeral_key: bytes):
98
123
  if self.ecc_mode is not None:
99
124
  expected_size = ECCMode.get_ec_compressed_pubkey_size(
@@ -112,6 +137,7 @@ class Header:
112
137
  total += 1 # ECC mode
113
138
  total += 1 # payload config
114
139
  total += self.policy_info.get_total_size() if self.policy_info else 0
140
+ total += self.GMAC_SIZE # policy binding (GMAC)
115
141
  total += len(self.ephemeral_key) if self.ephemeral_key else 0
116
142
  return total
117
143
 
@@ -132,6 +158,18 @@ class Header:
132
158
  # PolicyInfo
133
159
  n = self.policy_info.write_into_buffer(buffer, offset)
134
160
  offset += n
161
+ # Policy binding (GMAC - 8 bytes)
162
+ if self.policy_binding:
163
+ if len(self.policy_binding) != self.GMAC_SIZE:
164
+ raise ValueError(
165
+ f"Policy binding must be exactly {self.GMAC_SIZE} bytes (GMAC), got {len(self.policy_binding)}"
166
+ )
167
+ buffer[offset : offset + self.GMAC_SIZE] = self.policy_binding
168
+ offset += self.GMAC_SIZE
169
+ else:
170
+ # Write zeros if no binding provided
171
+ buffer[offset : offset + self.GMAC_SIZE] = b"\x00" * self.GMAC_SIZE
172
+ offset += self.GMAC_SIZE
135
173
  # Ephemeral key
136
174
  buffer[offset : offset + len(self.ephemeral_key)] = self.ephemeral_key
137
175
  offset += len(self.ephemeral_key)