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/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)
|