Skip to content

Commit e75f658

Browse files
authored
Add x25519 support (#403)
1 parent 6518bb8 commit e75f658

19 files changed

+559
-376
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
## 0.4.5
44

55
- Add homemade key pair: `ecies.keys.PrivateKey` and `ecies.keys.PublicKey`
6+
- Deprecate some functions in `ecies.utils`
67
- Bump dependencies
8+
- Add x25519 support
79

810
## 0.4.4
911

README.md

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ Other language versions:
1919
- [Java](https://github.com/ecies/java)
2020
- [Dart](https://github.com/ecies/dart)
2121

22-
You can also check a web backend demo [here](https://github.com/ecies/py-demo).
22+
You can also check a [web backend demo](https://github.com/ecies/py-demo).
2323

2424
## Install
2525

@@ -29,6 +29,8 @@ Or `pip install 'eciespy[eth]'` to install `eth-keys` as well.
2929

3030
## Quick Start
3131

32+
### Secp256k1
33+
3234
```python
3335
>>> from ecies.keys import PrivateKey
3436
>>> from ecies import encrypt, decrypt
@@ -39,11 +41,24 @@ Or `pip install 'eciespy[eth]'` to install `eth-keys` as well.
3941
>>> decrypt(sk_bytes, encrypt(pk_bytes, data)).decode()
4042
'hello world🌍'
4143
>>> sk_hex = sk.to_hex() # hex str
42-
>>> pk_hex = sk.public_key.to_hex() # hex str
44+
>>> pk_hex = sk.public_key.to_hex(True) # hex str
4345
>>> decrypt(sk_hex, encrypt(pk_hex, data)).decode()
4446
'hello world🌍'
4547
```
4648

49+
### X25519
50+
51+
```python
52+
>>> from ecies.keys import PrivateKey
53+
>>> from ecies import encrypt, decrypt
54+
>>> from ecies.config import ECIES_CONFIG
55+
>>> ECIES_CONFIG.elliptic_curve = 'x25519'
56+
>>> data = 'hello world🌍'.encode()
57+
>>> sk = PrivateKey('x25519')
58+
>>> decrypt(sk.secret, encrypt(sk.public_key.to_bytes(), data)).decode()
59+
'hello world🌍'
60+
```
61+
4762
Or just use a builtin command `eciespy` in your favorite [command line](#command-line-interface).
4863

4964
## API
@@ -120,26 +135,31 @@ $ rm sk pk data enc_data
120135
Ephemeral key format in the payload and shared key in the key derivation can be configured as compressed or uncompressed format.
121136

122137
```py
123-
from .consts import COMPRESSED_PUBLIC_KEY_SIZE, UNCOMPRESSED_PUBLIC_KEY_SIZE
124-
138+
EllipticCurve = Literal["secp256k1", "x25519"]
125139
SymmetricAlgorithm = Literal["aes-256-gcm", "xchacha20"]
126140
NonceLength = Literal[12, 16] # only for aes-256-gcm, xchacha20 will always be 24
127141

128142

129143
@dataclass()
130144
class Config:
145+
elliptic_curve: EllipticCurve = "secp256k1"
131146
is_ephemeral_key_compressed: bool = False
132147
is_hkdf_key_compressed: bool = False
133148
symmetric_algorithm: SymmetricAlgorithm = "aes-256-gcm"
134149
symmetric_nonce_length: NonceLength = 16
135150

136151
@property
137152
def ephemeral_key_size(self):
138-
return (
139-
COMPRESSED_PUBLIC_KEY_SIZE
140-
if self.is_ephemeral_key_compressed
141-
else UNCOMPRESSED_PUBLIC_KEY_SIZE
142-
)
153+
if self.elliptic_curve == "secp256k1":
154+
return (
155+
COMPRESSED_PUBLIC_KEY_SIZE
156+
if self.is_ephemeral_key_compressed
157+
else UNCOMPRESSED_PUBLIC_KEY_SIZE
158+
)
159+
elif self.elliptic_curve == "x25519":
160+
return CURVE25519_PUBLIC_KEY_SIZE
161+
else:
162+
raise NotImplementedError
143163

144164

145165
ECIES_CONFIG = Config()

ecies/config.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
from dataclasses import dataclass
22
from typing import Literal
33

4-
from .consts import COMPRESSED_PUBLIC_KEY_SIZE, UNCOMPRESSED_PUBLIC_KEY_SIZE
4+
from .consts import (
5+
COMPRESSED_PUBLIC_KEY_SIZE,
6+
CURVE25519_PUBLIC_KEY_SIZE,
7+
UNCOMPRESSED_PUBLIC_KEY_SIZE,
8+
)
59

6-
EllipticCurve = Literal["secp256k1"]
10+
EllipticCurve = Literal["secp256k1", "x25519"]
711
SymmetricAlgorithm = Literal["aes-256-gcm", "xchacha20"]
812
NonceLength = Literal[12, 16] # only for aes-256-gcm, xchacha20 will always be 24
913

@@ -18,11 +22,16 @@ class Config:
1822

1923
@property
2024
def ephemeral_key_size(self):
21-
return (
22-
COMPRESSED_PUBLIC_KEY_SIZE
23-
if self.is_ephemeral_key_compressed
24-
else UNCOMPRESSED_PUBLIC_KEY_SIZE
25-
)
25+
if self.elliptic_curve == "secp256k1":
26+
return (
27+
COMPRESSED_PUBLIC_KEY_SIZE
28+
if self.is_ephemeral_key_compressed
29+
else UNCOMPRESSED_PUBLIC_KEY_SIZE
30+
)
31+
elif self.elliptic_curve == "x25519":
32+
return CURVE25519_PUBLIC_KEY_SIZE
33+
else:
34+
raise NotImplementedError
2635

2736

2837
ECIES_CONFIG = Config()

ecies/consts.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# elliptic
2+
SECRET_KEY_SIZE = 32
23
COMPRESSED_PUBLIC_KEY_SIZE = 33
34
UNCOMPRESSED_PUBLIC_KEY_SIZE = 65
5+
CURVE25519_PUBLIC_KEY_SIZE = 32
46
ETH_PUBLIC_KEY_LENGTH = 64
57

68
# symmetric

ecies/keys/helper.py

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,30 @@
11
from coincurve import PublicKey
22
from coincurve.utils import GROUP_ORDER_INT
3+
from Crypto.Protocol.DH import (
4+
import_x25519_private_key,
5+
import_x25519_public_key,
6+
key_agreement,
7+
)
38
from Crypto.Random import get_random_bytes
49

510
from ..config import EllipticCurve
6-
from ..consts import ETH_PUBLIC_KEY_LENGTH
11+
from ..consts import ETH_PUBLIC_KEY_LENGTH, SECRET_KEY_SIZE
712

813

914
def is_valid_secret(curve: EllipticCurve, secret: bytes) -> bool:
15+
if len(secret) != SECRET_KEY_SIZE:
16+
return False
1017
if curve == "secp256k1":
1118
return 0 < bytes_to_int(secret) < GROUP_ORDER_INT
12-
raise NotImplementedError
19+
elif curve == "x25519":
20+
return True
21+
else:
22+
raise NotImplementedError
1323

1424

1525
def get_valid_secret(curve: EllipticCurve) -> bytes:
1626
while True:
17-
key = get_random_bytes(32)
27+
key = get_random_bytes(SECRET_KEY_SIZE)
1828
if is_valid_secret(curve, key):
1929
return key
2030

@@ -24,15 +34,25 @@ def get_public_key(
2434
) -> bytes:
2535
if curve == "secp256k1":
2636
return PublicKey.from_secret(secret).format(compressed)
27-
raise NotImplementedError
37+
elif curve == "x25519":
38+
return import_x25519_private_key(secret).public_key().export_key(format="raw")
39+
else:
40+
raise NotImplementedError
2841

2942

3043
def get_shared_point(
3144
curve: EllipticCurve, sk: bytes, pk: bytes, compressed: bool = False
3245
) -> bytes:
3346
if curve == "secp256k1":
3447
return PublicKey(pk).multiply(sk).format(compressed)
35-
raise NotImplementedError
48+
elif curve == "x25519":
49+
return key_agreement(
50+
kdf=lambda x: x,
51+
static_priv=import_x25519_private_key(sk),
52+
eph_pub=import_x25519_public_key(pk),
53+
)
54+
else:
55+
raise NotImplementedError
3656

3757

3858
def convert_public_key(
@@ -41,7 +61,10 @@ def convert_public_key(
4161
if curve == "secp256k1":
4262
# handle 33/65/64 bytes
4363
return PublicKey(pad_eth_public_key(data)).format(compressed)
44-
raise NotImplementedError
64+
elif curve == "x25519":
65+
return import_x25519_public_key(data).export_key(format="raw")
66+
else:
67+
raise NotImplementedError
4568

4669

4770
# private below

ecies/keys/private.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,19 @@
1616

1717
class PrivateKey:
1818
def __init__(self, curve: EllipticCurve, secret: Optional[bytes] = None):
19-
self._curve = curve
19+
self._curve: EllipticCurve = curve
2020
if not secret:
21-
self._secret = get_valid_secret(curve)
22-
elif is_valid_secret(curve, secret):
21+
self._secret = get_valid_secret(self._curve)
22+
elif is_valid_secret(self._curve, secret):
2323
self._secret = secret
2424
else:
25-
raise ValueError(f"Invalid {curve} secret key")
26-
self._public_key = PublicKey(curve, get_public_key(curve, self._secret))
25+
raise ValueError(f"Invalid {self._curve} secret key")
26+
self._public_key = PublicKey(
27+
self._curve, get_public_key(self._curve, self._secret)
28+
)
29+
30+
def __repr__(self):
31+
return f"PrivateKey('{self._curve}', {self._secret})"
2732

2833
def __eq__(self, value):
2934
return self._secret == value._secret if isinstance(value, PrivateKey) else False

ecies/keys/public.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ def __init__(self, curve: EllipticCurve, data: bytes):
2020
uncompressed if len(compressed) != len(uncompressed) else b""
2121
)
2222

23+
def __repr__(self):
24+
return f"PublicKey('{self._curve}', {self._data})"
25+
2326
def __eq__(self, value):
2427
return self._data == value._data if isinstance(value, PublicKey) else False
2528

@@ -40,7 +43,9 @@ def to_bytes(self, compressed: bool = False) -> bytes:
4043
"""
4144
For secp256k1, return uncompressed public key (65 bytes) by default
4245
"""
43-
return self._data if compressed else self._data_uncompressed
46+
if not compressed and self._data_uncompressed:
47+
return self._data_uncompressed
48+
return self._data
4449

4550
def decapsulate(self, sk: PrivateKey, compressed: bool = False) -> bytes:
4651
sender_point = self.to_bytes(compressed)

ecies/utils/elliptic.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from .hex import decode_hex
88

99

10-
@deprecated("Use `ecies.keys.PrivateKey` or `coincurve.PrivateKey` instead")
10+
@deprecated("Use `ecies.keys.PrivateKey` instead")
1111
def generate_key() -> PrivateKey:
1212
"""
1313
Generate a random coincurve.PrivateKey`

ecies/utils/eth.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ def generate_eth_key():
1818
An ethereum flavored secp256k1 key
1919
2020
"""
21-
from eth_keys import keys
21+
from eth_keys import keys # type:ignore
2222

2323
return keys.PrivateKey(get_valid_secret())
2424

ecies/utils/hash.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,6 @@ def sha256(data: bytes) -> bytes:
1111

1212

1313
def derive_key(master: bytes, salt: bytes = b"") -> bytes:
14-
# for aes256 and xchacha20
14+
# 32 bytes for aes256 and xchacha20
1515
derived = HKDF(master, 32, salt, SHA256, num_keys=1)
1616
return derived # type: ignore

0 commit comments

Comments
 (0)