diff --git a/.coveragerc b/.coveragerc index 876cd72..a9e3436 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,6 +1,6 @@ [run] -include = - ecies/__init__.py - ecies/utils.py omit = ecies/__main__.py +[report] +exclude_also = + raise NotImplementedError diff --git a/.cspell.jsonc b/.cspell.jsonc new file mode 100644 index 0000000..14b4c06 --- /dev/null +++ b/.cspell.jsonc @@ -0,0 +1,25 @@ +{ + "words": [ + "bitcointalk", + "Cipolla", + "Codacy", + "Codecov", + "coincurve", + "dataclass", + "ecdh", + "ecies", + "eciespy", + "fromhex", + "hashlib", + "helloworld", + "hexdigest", + "hkdf", + "pycryptodome", + "readablize", + "secp", + "urandom", + "xcfl", + "xchacha" + ], + "ignorePaths": [".cspell.jsonc", "LICENSE"] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b542294..3146876 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11"] # "3.12-dev" steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 @@ -31,7 +31,10 @@ jobs: - run: brew install automake if: matrix.os == 'macos-latest' - - run: poetry install --only main + - run: poetry install + + - name: Run test + run: poetry run pytest -s --cov=ecies tests --cov-report xml - run: ./scripts/ci.sh diff --git a/.gitignore b/.gitignore index 1285c9c..f061d78 100644 --- a/.gitignore +++ b/.gitignore @@ -106,6 +106,6 @@ venv.bak/ .vscode/ .DS_Store -prv -pub +sk +pk out diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5e03499 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,35 @@ +# Release Notes + +## 0.4.0 + +- Drop Python 3.7 +- Revamp documentation +- Add configuration and XChaCha20 as an optional encryption backend + +## 0.3.1 ~ 0.3.13 + +- Support Python 3.8, 3.9, 3.10, 3.11 +- Drop Python 3.5, 3.6 +- Bump dependencies +- Update documentation + +## 0.3.0 + +- API change: use `HKDF-sha256` to derive shared keys instead of `sha256` + +## 0.2.0 + +- API change: `ecies.encrypt` and `ecies.decrypt` now can take both hex `str` and raw `bytes` +- Bump dependencies +- Update documentation + +## 0.1.1 ~ 0.1.9 + +- Bump dependencies +- Update documentation +- Switch to Circle CI +- Change license to MIT + +## 0.1.0 + +- First beta version release diff --git a/DETAILS.md b/DETAILS.md new file mode 100644 index 0000000..94cae07 --- /dev/null +++ b/DETAILS.md @@ -0,0 +1,141 @@ +# Mechanism and implementation + +This library combines `secp256k1` and `AES-256-GCM` (powered by [`coincurve`](https://github.com/ofek/coincurve) and [`pycryptodome`](https://github.com/Legrandin/pycryptodome)) to provide an API of encrypting with `secp256k1` public key and decrypting with `secp256k1`'s private key. It has two parts generally: + +1. Use [ECDH](https://en.wikipedia.org/wiki/Elliptic-curve_Diffie–Hellman) to exchange an AES session key; + + > Notice that the sender public key is generated every time when `ecies.encrypt` is invoked, thus, the AES session key varies. + > + > We are using HKDF-SHA256 instead of SHA256 to derive the AES keys. + +2. Use this AES session key to encrypt/decrypt the data under `AES-256-GCM`. + +Basically the encrypted data will be like this: + +```plaintext ++-------------------------------+----------+----------+-----------------+ +| 65 Bytes | 16 Bytes | 16 Bytes | == data size | ++-------------------------------+----------+----------+-----------------+ +| Sender Public Key (ephemeral) | Nonce/IV | Tag/MAC | Encrypted data | ++-------------------------------+----------+----------+-----------------+ +| sender_pk | nonce | tag | encrypted_data | ++-------------------------------+----------+----------+-----------------+ +| Secp256k1 | AES-256-GCM | ++-------------------------------+---------------------------------------+ +``` + +## Secp256k1 + +### Glance at ECDH + +So, **how** do we calculate the ECDH key under `secp256k1`? If you use a library like [`coincurve`](https://github.com/ofek/coincurve), you might just simply call `k1.ecdh(k2.public_key.format())`, then uh-huh, you got it! Let's see how to do it in simple Python snippets: + +```python +>>> from coincurve import PrivateKey +>>> k1 = PrivateKey.from_int(3) +>>> k2 = PrivateKey.from_int(2) +>>> k1.public_key.format(False).hex() # 65 bytes, False means uncompressed key +'04f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9388f7b0f632de8140fe337e62a37f3566500a99934c2231b6cb9fd7584b8e672' +>>> k2.public_key.format(False).hex() # 65 bytes +'04c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee51ae168fea63dc339a3c58419466ceaeef7f632653266d0e1236431a950cfe52a' +>>> k1.ecdh(k2.public_key.format()).hex() +'c7d9ba2fa1496c81be20038e5c608f2fd5d0246d8643783730df6c2bbb855cb2' +>>> k2.ecdh(k1.public_key.format()).hex() +'c7d9ba2fa1496c81be20038e5c608f2fd5d0246d8643783730df6c2bbb855cb2' +``` + +### Calculate your ecdh key manually + +However, as a hacker like you with strong desire to learn something, you must be curious about the magic under the ground. + +In one sentence, the `secp256k1`'s ECDH key of `k1` and `k2` is nothing but `sha256(k2.public_key.multiply(k1))`. + +```python +>>> k1.to_int() +3 +>>> shared = k2.public_key.multiply(k1.secret) +>>> shared.point() +(115780575977492633039504758427830329241728645270042306223540962614150928364886, + 78735063515800386211891312544505775871260717697865196436804966483607426560663) +>>> import hashlib +>>> h = hashlib.sha256() +>>> h.update(shared.format()) +>>> h.hexdigest() # here you got the ecdh key same as above! +'c7d9ba2fa1496c81be20038e5c608f2fd5d0246d8643783730df6c2bbb855cb2' +``` + +> Warning: **NEVER** use small integers as private keys on any production systems or storing any valuable assets. +> +> Warning: **ALWAYS** use safe methods like [`os.urandom`](https://docs.python.org/3/library/os.html#os.urandom) to generate private keys. + +### Math on ecdh + +Let's discuss in details. The word _multiply_ here means multiplying a **point** of a public key on elliptic curve (like `(x, y)`) with a **scalar** (like `k`). Here `k` is the integer format of a private key, for instance, it can be `3` for `k1` here, and `(x, y)` here is an extremely large number pair like `(115780575977492633039504758427830329241728645270042306223540962614150928364886, 78735063515800386211891312544505775871260717697865196436804966483607426560663)`. + +> Warning: 1 \* (x, y) == (x, y) is always true, since 1 is the **identity element** for multiplication. If you take integer 1 as a private key, the public key will be the [base point](https://en.wikipedia.org/wiki/Elliptic_Curve_Digital_Signature_Algorithm#Signature_generation_algorithm). + +Mathematically, the elliptic curve cryptography is based on the fact that you can easily multiply point `A` (aka base point, or public key in ECDH) and scalar `k` (aka private key) to get another point `B` (aka public key), but it's almost impossible to calculate `A` from `B` reversely (which means it's a "one-way function"). + +### Compressed and uncompressed keys + +A point multiplying a scalar can be regarded that this point adds itself multiple times, and the point `B` can be converted to a readable public key in a compressed or uncompressed format. + +- Compressed format (`x` coordinate only) + +```python +>>> point = (89565891926547004231252920425935692360644145829622209833684329913297188986597, 12158399299693830322967808612713398636155367887041628176798871954788371653930) +>>> point == k2.public_key.point() +True +>>> prefix = '02' if point[1] % 2 == 0 else '03' +>>> compressed_key_hex = prefix + hex(point[0])[2:] +>>> compressed_key = bytes.fromhex(compressed_key_hex) +>>> compressed_key.hex() +'02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5' +``` + +- Uncompressed format (`(x, y)` coordinate) + +```python +>>> uncompressed_key_hex = '04' + hex(point[0])[2:] + hex(point[1])[2:] +>>> uncompressed_key = bytes.fromhex(uncompressed_key_hex) +>>> uncompressed_key.hex() +'04c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee51ae168fea63dc339a3c58419466ceaeef7f632653266d0e1236431a950cfe52a' +``` + +The format is depicted by the image below from the [bitcoin book](https://github.com/bitcoinbook/bitcoinbook). + +![EC public key format](https://raw.githubusercontent.com/bitcoinbook/bitcoinbook/develop/images/mbc2_0407.png) + +> If you want to convert the compressed format to uncompressed, basically, you need to calculate `y` from `x` by solving the equation using [Cipolla's Algorithm](https://en.wikipedia.org/wiki/Cipolla's_algorithm): +> +> ![y^2=(x^3 + 7) mod p, where p=2^{256}-2^{32}-2^{9}-2^{8}-2^{7}-2^{6}-2^{4}-1]() +> +> You can check the [bitcoin wiki](https://en.bitcoin.it/wiki/Secp256k1) and this thread on [bitcointalk.org](https://bitcointalk.org/index.php?topic=644919.msg7205689#msg7205689) for more details. + +Then, the shared key between `k1` and `k2` is the `sha256` hash of the **compressed** ECDH public key. It's better to use the compressed format, since you can always get `x` from `x` or `(x, y)` without any calculation. + +You may want to ask, what if we don't hash it? Briefly, hash can: + +1. Make the shared key's length fixed; +2. Make it safer since hash functions can remove "weak bits" in the original computed key. Check the introduction section of this [paper](http://cacr.uwaterloo.ca/techreports/1998/corr98-05.pdf) for more details. + +> Warning: According to some recent research, although widely used, the `sha256` key derivation function is [not secure enough](https://github.com/ecies/py/issues/82). + +## AES + +Now we have the shared key, and we can use the `nonce` and `tag` to decrypt. This is quite straight, and the example derives from `pycryptodome`'s [documentation](https://pycryptodome.readthedocs.io/en/latest/src/examples.html#encrypt-data-with-aes). + +```python +>>> from Crypto.Cipher import AES +>>> key = b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' +>>> nonce = b'\xf3\xe1\xba\x81\r,\x89\x00\xb1\x13\x12\xb7\xc7%V_' +>>> tag = b'\xec;q\xe1|\x11\xdb\xe3\x14\x84\xda\x94P\xed\xcfl' +>>> data = b'\x02\xd2\xff\xed\x93\xb8V\xf1H\xb9' +>>> decipher = AES.new(key, AES.MODE_GCM, nonce=nonce) +>>> decipher.decrypt_and_verify(data, tag) +b'helloworld' +``` + +> Strictly speaking, `nonce` != `iv`, but this is a little bit off topic, if you are curious, you can check [the comment in `utils/symmetric.py`](./ecies/utils/symmetric.py#L79). +> +> Warning: it's dangerous to reuse nonce, if you don't know what you are doing, just follow the default setting. diff --git a/LICENSE b/LICENSE index d5c0187..437b2f4 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2018-2021 Weiliang Li +Copyright (c) 2018-2023 Weiliang Li Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 341e18f..bd11551 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ You can also check a FastAPI web backend demo [here](https://github.com/kigawas/ ## Install -Install with `pip install eciespy` under Python 3.7+. +`pip install eciespy` ## Quick Start @@ -98,183 +98,62 @@ Address: 0x47e801184B3a8ea8E6A4A7A4CFEfEcC76809Da72 ### Encrypt with public key and decrypt with private key ```console -$ echo '0x95d3c5e483e9b1d4f5fc8e79b2deaf51362980de62dbb082a9a4257eef653d7d' > prv -$ echo '0x98afe4f150642cd05cc9d2fa36458ce0a58567daeaf5fde7333ba9b403011140a4e28911fcf83ab1f457a30b4959efc4b9306f514a4c3711a16a80e3b47eb58b' > pub -$ echo 'helloworld' | eciespy -e -k pub | eciespy -d -k prv -helloworld +$ echo '0x95d3c5e483e9b1d4f5fc8e79b2deaf51362980de62dbb082a9a4257eef653d7d' > sk +$ echo '0x98afe4f150642cd05cc9d2fa36458ce0a58567daeaf5fde7333ba9b403011140a4e28911fcf83ab1f457a30b4959efc4b9306f514a4c3711a16a80e3b47eb58b' > pk +$ echo 'hello ecies' | eciespy -e -k pk | eciespy -d -k sk +hello ecies $ echo 'data to encrypt' > data -$ eciespy -e -k pub -D data -O enc_data -$ eciespy -d -k prv -D enc_data +$ eciespy -e -k pk -D data -O enc_data +$ eciespy -d -k sk -D enc_data data to encrypt -$ rm prv pub data enc_data +$ rm sk pk data enc_data ``` -## Mechanism and implementation details +## Configuration -This library combines `secp256k1` and `AES-256-GCM` (powered by [`coincurve`](https://github.com/ofek/coincurve) and [`pycryptodome`](https://github.com/Legrandin/pycryptodome)) to provide an API of encrypting with `secp256k1` public key and decrypting with `secp256k1`'s private key. It has two parts generally: +Ephemeral key format in the payload and shared key in the key derivation can be configured as compressed or uncompressed format. -1. Use [ECDH](https://en.wikipedia.org/wiki/Elliptic-curve_Diffie–Hellman) to exchange an AES session key; +```py +SymmetricAlgorithm = Literal["aes-256-gcm", "xchacha20"] +NonceLength = Literal[12, 16] # only for aes-256-gcm, xchacha20 will always be 24 - > Notice that the sender public key is generated every time when `ecies.encrypt` is invoked, thus, the AES session key varies. - > - > We are using HKDF-SHA256 instead of SHA256 to derive the AES keys. +COMPRESSED_PUBLIC_KEY_SIZE = 33 +UNCOMPRESSED_PUBLIC_KEY_SIZE = 65 -2. Use this AES session key to encrypt/decrypt the data under `AES-256-GCM`. -Basically the encrypted data will be like this: +@dataclass() +class Config: + is_ephemeral_key_compressed: bool = False + is_hkdf_key_compressed: bool = False + symmetric_algorithm: SymmetricAlgorithm = "aes-256-gcm" + symmetric_nonce_length: NonceLength = 16 -```plaintext -+-------------------------------+----------+----------+-----------------+ -| 65 Bytes | 16 Bytes | 16 Bytes | == data size | -+-------------------------------+----------+----------+-----------------+ -| Sender Public Key (ephemeral) | Nonce/IV | Tag/MAC | Encrypted data | -+-------------------------------+----------+----------+-----------------+ -| sender_pk | nonce | tag | encrypted_data | -+-------------------------------+----------+----------+-----------------+ -| Secp256k1 | AES-256-GCM | -+-------------------------------+---------------------------------------+ -``` - -### Secp256k1 - -#### Glance at ECDH - -So, **how** do we calculate the ECDH key under `secp256k1`? If you use a library like [`coincurve`](https://github.com/ofek/coincurve), you might just simply call `k1.ecdh(k2.public_key.format())`, then uh-huh, you got it! Let's see how to do it in simple Python snippets: - -```python ->>> from coincurve import PrivateKey ->>> k1 = PrivateKey.from_int(3) ->>> k2 = PrivateKey.from_int(2) ->>> k1.public_key.format(False).hex() # 65 bytes, False means uncompressed key -'04f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9388f7b0f632de8140fe337e62a37f3566500a99934c2231b6cb9fd7584b8e672' ->>> k2.public_key.format(False).hex() # 65 bytes -'04c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee51ae168fea63dc339a3c58419466ceaeef7f632653266d0e1236431a950cfe52a' ->>> k1.ecdh(k2.public_key.format()).hex() -'c7d9ba2fa1496c81be20038e5c608f2fd5d0246d8643783730df6c2bbb855cb2' ->>> k2.ecdh(k1.public_key.format()).hex() -'c7d9ba2fa1496c81be20038e5c608f2fd5d0246d8643783730df6c2bbb855cb2' -``` - -#### Calculate your ecdh key manually - -However, as a hacker like you with strong desire to learn something, you must be curious about the magic under the ground. - -In one sentence, the `secp256k1`'s ECDH key of `k1` and `k2` is nothing but `sha256(k2.public_key.multiply(k1))`. - -```python ->>> k1.to_int() -3 ->>> shared_pub = k2.public_key.multiply(k1.secret) ->>> shared_pub.point() -(115780575977492633039504758427830329241728645270042306223540962614150928364886, - 78735063515800386211891312544505775871260717697865196436804966483607426560663) ->>> import hashlib ->>> h = hashlib.sha256() ->>> h.update(shared_pub.format()) ->>> h.hexdigest() # here you got the ecdh key same as above! -'c7d9ba2fa1496c81be20038e5c608f2fd5d0246d8643783730df6c2bbb855cb2' -``` - -> Warning: **NEVER** use small integers as private keys on any production systems or storing any valuable assets. -> -> Warning: **ALWAYS** use safe methods like [`os.urandom`](https://docs.python.org/3/library/os.html#os.urandom) to generate private keys. - -#### Math on ecdh - -Let's discuss in details. The word _multiply_ here means multiplying a **point** of a public key on elliptic curve (like `(x, y)`) with a **scalar** (like `k`). Here `k` is the integer format of a private key, for instance, it can be `3` for `k1` here, and `(x, y)` here is an extremely large number pair like `(115780575977492633039504758427830329241728645270042306223540962614150928364886, 78735063515800386211891312544505775871260717697865196436804966483607426560663)`. - -> Warning: 1 \* (x, y) == (x, y) is always true, since 1 is the **identity element** for multiplication. If you take integer 1 as a private key, the public key will be the [base point](https://en.wikipedia.org/wiki/Elliptic_Curve_Digital_Signature_Algorithm#Signature_generation_algorithm). - -Mathematically, the elliptic curve cryptography is based on the fact that you can easily multiply point `A` (aka base point, or public key in ECDH) and scalar `k` (aka private key) to get another point `B` (aka public key), but it's almost impossible to calculate `A` from `B` reversely (which means it's a "one-way function"). - -#### Compressed and uncompressed keys - -A point multiplying a scalar can be regarded that this point adds itself multiple times, and the point `B` can be converted to a readable public key in a compressed or uncompressed format. + @property + def ephemeral_key_size(self): + return ( + COMPRESSED_PUBLIC_KEY_SIZE + if self.is_ephemeral_key_compressed + else UNCOMPRESSED_PUBLIC_KEY_SIZE + ) -- Compressed format (`x` coordinate only) -```python ->>> point = (89565891926547004231252920425935692360644145829622209833684329913297188986597, 12158399299693830322967808612713398636155367887041628176798871954788371653930) ->>> point == k2.public_key.point() -True ->>> prefix = '02' if point[1] % 2 == 0 else '03' ->>> compressed_key_hex = prefix + hex(point[0])[2:] ->>> compressed_key = bytes.fromhex(compressed_key_hex) ->>> compressed_key.hex() -'02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5' -``` - -- Uncompressed format (`(x, y)` coordinate) - -```python ->>> uncompressed_key_hex = '04' + hex(point[0])[2:] + hex(point[1])[2:] ->>> uncompressed_key = bytes.fromhex(uncompressed_key_hex) ->>> uncompressed_key.hex() -'04c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee51ae168fea63dc339a3c58419466ceaeef7f632653266d0e1236431a950cfe52a' -``` - -The format is depicted by the image below from the [bitcoin book](https://github.com/bitcoinbook/bitcoinbook). - -![EC public key format](https://raw.githubusercontent.com/bitcoinbook/bitcoinbook/develop/images/mbc2_0407.png) - -> If you want to convert the compressed format to uncompressed, basically, you need to calculate `y` from `x` by solving the equation using [Cipolla's Algorithm](https://en.wikipedia.org/wiki/Cipolla's_algorithm): -> -> ![y^2=(x^3 + 7) mod p, where p=2^{256}-2^{32}-2^{9}-2^{8}-2^{7}-2^{6}-2^{4}-1]() -> -> You can check the [bitcoin wiki](https://en.bitcoin.it/wiki/Secp256k1) and this thread on [bitcointalk.org](https://bitcointalk.org/index.php?topic=644919.msg7205689#msg7205689) for more details. - -Then, the shared key between `k1` and `k2` is the `sha256` hash of the **compressed** ECDH public key. It's better to use the compressed format, since you can always get `x` from `x` or `(x, y)` without any calculation. - -You may want to ask, what if we don't hash it? Briefly, hash can: - -1. Make the shared key's length fixed; -2. Make it safer since hash functions can remove "weak bits" in the original computed key. Check the introduction section of this [paper](http://cacr.uwaterloo.ca/techreports/1998/corr98-05.pdf) for more details. - -> Warning: According to some recent research, although widely used, the `sha256` key derivation function is [not secure enough](https://github.com/ecies/py/issues/82). - -### AES - -Now we have the shared key, and we can use the `nonce` and `tag` to decrypt. This is quite straight, and the example derives from `pycryptodome`'s [documentation](https://pycryptodome.readthedocs.io/en/latest/src/examples.html#encrypt-data-with-aes). - -```python ->>> from Crypto.Cipher import AES ->>> key = b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' ->>> iv = b'\xf3\xe1\xba\x81\r,\x89\x00\xb1\x13\x12\xb7\xc7%V_' ->>> tag = b'\xec;q\xe1|\x11\xdb\xe3\x14\x84\xda\x94P\xed\xcfl' ->>> data = b'\x02\xd2\xff\xed\x93\xb8V\xf1H\xb9' ->>> decipher = AES.new(key, AES.MODE_GCM, nonce=iv) ->>> decipher.decrypt_and_verify(data, tag) -b'helloworld' +ECIES_CONFIG = Config() ``` -> Strictly speaking, `nonce` != `iv`, but this is a little bit off topic, if you are curious, you can check [the comment in `utils.py`](https://github.com/ecies/py/blob/master/ecies/utils.py#L213). - -## Release Notes - -### 0.3.1 ~ 0.3.13 - -- Support Python 3.8, 3.9, 3.10, 3.11 -- Drop Python 3.5, 3.6 -- Bump dependencies -- Update documentation +For example, if you set `is_ephemeral_key_compressed = true`, the payload would be like: `33 Bytes + AES` instead of `65 Bytes + AES`. -### 0.3.0 +If you set `is_hkdf_key_compressed = true`, the hkdf key would be derived from `ephemeral public key (compressed) + shared public key (compressed)` instead of `ephemeral public key (uncompressed) + shared public key (uncompressed)`. -- API change: use `HKDF-sha256` to derive shared keys instead of `sha256` +If you set `symmetric_algorithm = "xchacha20"`, plaintext data will encrypted with XChaCha20-Poly1305. -### 0.2.0 +If you set `symmetric_nonce_length = 12`, then the nonce of aes-256-gcm would be 12 bytes. XChaCha20-Poly1305's nonce is always 24 bytes. -- API change: `ecies.encrypt` and `ecies.decrypt` now can take both hex `str` and raw `bytes` -- Bump dependencies -- Update documentation +For compatibility, make sure different applications share the same configuration. -### 0.1.1 ~ 0.1.9 +## Technical details -- Bump dependencies -- Update documentation -- Switch to Circle CI -- Change license to MIT +They are moved to [DETAILS.md](./DETAILS.md). -### 0.1.0 +## Changelog -- First beta version release +See [CHANGELOG.md](./CHANGELOG.md). diff --git a/ecies/__init__.py b/ecies/__init__.py index 4f50bca..2a41ebf 100644 --- a/ecies/__init__.py +++ b/ecies/__init__.py @@ -1,9 +1,19 @@ from typing import Union from coincurve import PrivateKey, PublicKey -from ecies.utils import generate_key, hex2prv, hex2pub, encapsulate, decapsulate, aes_encrypt, aes_decrypt -__all__ = ["encrypt", "decrypt"] +from .config import ECIES_CONFIG +from .utils import ( + decapsulate, + encapsulate, + generate_key, + hex2pk, + hex2sk, + sym_decrypt, + sym_encrypt, +) + +__all__ = ["encrypt", "decrypt", "ECIES_CONFIG"] def encrypt(receiver_pk: Union[str, bytes], msg: bytes) -> bytes: @@ -22,17 +32,21 @@ def encrypt(receiver_pk: Union[str, bytes], msg: bytes) -> bytes: bytes Encrypted data """ - ephemeral_key = generate_key() if isinstance(receiver_pk, str): - receiver_pubkey = hex2pub(receiver_pk) + pk = hex2pk(receiver_pk) elif isinstance(receiver_pk, bytes): - receiver_pubkey = PublicKey(receiver_pk) + pk = PublicKey(receiver_pk) else: raise TypeError("Invalid public key type") - aes_key = encapsulate(ephemeral_key, receiver_pubkey) - cipher_text = aes_encrypt(aes_key, msg) - return ephemeral_key.public_key.format(False) + cipher_text + ephemeral_sk = generate_key() + ephemeral_pk = ephemeral_sk.public_key.format( + ECIES_CONFIG.is_ephemeral_key_compressed + ) + + sym_key = encapsulate(ephemeral_sk, pk) + encrypted = sym_encrypt(sym_key, msg) + return ephemeral_pk + encrypted def decrypt(receiver_sk: Union[str, bytes], msg: bytes) -> bytes: @@ -52,15 +66,14 @@ def decrypt(receiver_sk: Union[str, bytes], msg: bytes) -> bytes: Plain text """ if isinstance(receiver_sk, str): - private_key = hex2prv(receiver_sk) + sk = hex2sk(receiver_sk) elif isinstance(receiver_sk, bytes): - private_key = PrivateKey(receiver_sk) + sk = PrivateKey(receiver_sk) else: raise TypeError("Invalid secret key type") - pubkey = msg[0:65] # uncompressed pubkey's length is 65 bytes - encrypted = msg[65:] - ephemeral_public_key = PublicKey(pubkey) + key_size = ECIES_CONFIG.ephemeral_key_size + ephemeral_pk, encrypted = PublicKey(msg[0:key_size]), msg[key_size:] - aes_key = decapsulate(ephemeral_public_key, private_key) - return aes_decrypt(aes_key, encrypted) + sym_key = decapsulate(ephemeral_pk, sk) + return sym_decrypt(sym_key, encrypted) diff --git a/ecies/__main__.py b/ecies/__main__.py index 97d2f9a..e58a618 100644 --- a/ecies/__main__.py +++ b/ecies/__main__.py @@ -12,7 +12,7 @@ import argparse import sys -from ecies import encrypt, decrypt +from ecies import decrypt, encrypt from ecies.utils import generate_eth_key __description__ = "Elliptic Curve Integrated Encryption Scheme for secp256k1 in Python" @@ -68,12 +68,12 @@ def main(): args = parser.parse_args() if args.generate: k = generate_eth_key() - prv, pub, addr = ( + sk, pub, addr = ( k.to_hex(), k.public_key.to_hex(), k.public_key.to_checksum_address(), ) - print("Private: {}\nPublic: {}\nAddress: {}".format(prv, pub, addr)) + print("Private: {}\nPublic: {}\nAddress: {}".format(sk, pub, addr)) return if args.encrypt == args.decrypt: @@ -86,18 +86,18 @@ def main(): key = args.key.read().strip() if args.encrypt: - plaintext = args.data.read() - if isinstance(plaintext, str): - plaintext = plaintext.encode() - data = encrypt(key, plaintext) + plain_text = args.data.read() + if isinstance(plain_text, str): + plain_text = plain_text.encode() + data = encrypt(key, plain_text) if args.out == sys.stdout: data = data.hex() else: - ciphertext = args.data.read() - if isinstance(ciphertext, str): + cipher_text = args.data.read() + if isinstance(cipher_text, str): # if not bytes, suppose hex string - ciphertext = bytes.fromhex(ciphertext.strip()) - data = decrypt(key, ciphertext) + cipher_text = bytes.fromhex(cipher_text.strip()) + data = decrypt(key, cipher_text) if args.out == sys.stdout: # if binary data, print hex; if not, print utf8 data = readablize(data) diff --git a/ecies/config.py b/ecies/config.py new file mode 100644 index 0000000..9fc47fc --- /dev/null +++ b/ecies/config.py @@ -0,0 +1,27 @@ +from dataclasses import dataclass +from typing import Literal + +SymmetricAlgorithm = Literal["aes-256-gcm", "xchacha20"] +NonceLength = Literal[12, 16] # only for aes-256-gcm, xchacha20 will always be 24 + +COMPRESSED_PUBLIC_KEY_SIZE = 33 +UNCOMPRESSED_PUBLIC_KEY_SIZE = 65 + + +@dataclass() +class Config: + is_ephemeral_key_compressed: bool = False + is_hkdf_key_compressed: bool = False + symmetric_algorithm: SymmetricAlgorithm = "aes-256-gcm" + symmetric_nonce_length: NonceLength = 16 + + @property + def ephemeral_key_size(self): + return ( + COMPRESSED_PUBLIC_KEY_SIZE + if self.is_ephemeral_key_compressed + else UNCOMPRESSED_PUBLIC_KEY_SIZE + ) + + +ECIES_CONFIG = Config() diff --git a/ecies/utils.py b/ecies/utils.py deleted file mode 100644 index b77a092..0000000 --- a/ecies/utils.py +++ /dev/null @@ -1,218 +0,0 @@ -import hashlib -import codecs - -from Crypto.Cipher import AES -from Crypto.Protocol.KDF import HKDF -from Crypto.Hash import SHA256 -from coincurve import PrivateKey, PublicKey -from coincurve.utils import get_valid_secret -from eth_keys import keys - -AES_CIPHER_MODE = AES.MODE_GCM -AES_KEY_BYTES_LEN = 32 - -__all__ = [ - "sha256", - "generate_key", - "generate_eth_key", - "hex2prv", - "hex2pub", - "aes_encrypt", - "aes_decrypt", -] - - -def remove_0x(s: str) -> str: - if s.startswith("0x") or s.startswith("0X"): - return s[2:] - return s - - -def decode_hex(s: str) -> bytes: - return codecs.decode(remove_0x(s), "hex") # type: ignore - - -def sha256(msg: bytes) -> bytes: - """ - Calculate sha256 hash. - - Parameters - ---------- - msg: bytes - message to hash - - Returns - ------- - bytes - sha256 hash in bytes - - >>> sha256(b'0'*16).hex()[:8] == 'fcdb4b42' - True - """ - return hashlib.sha256(msg).digest() - - -def generate_key() -> PrivateKey: - """ - Generate random (or disposable) EC private key - - Returns - ------- - coincurve.PrivateKey - A secp256k1 key - - >>> k = generate_key() - """ - return PrivateKey(get_valid_secret()) - - -def generate_eth_key() -> keys.PrivateKey: - """ - Generate random eth private key - - Returns - ------- - eth_keys.keys.PrivateKey - An ethereum key - - >>> k = generate_eth_key() - """ - return keys.PrivateKey(get_valid_secret()) - - -def hex2pub(pub_hex: str) -> PublicKey: - """ - Convert ethereum hex to EllipticCurvePublicKey - The hex should be 65 bytes, but ethereum public key only has 64 bytes - So have to add \x04 - - Parameters - ---------- - pub_hex: str - Public key hex string - - Returns - ------- - coincurve.PublicKey - A secp256k1 public key - - >>> data = b'0'*32 - >>> data_hash = sha256(data) - >>> eth_prv = generate_eth_key() - >>> cc_prv = hex2prv(eth_prv.to_hex()) - >>> eth_prv.sign_msg_hash(data_hash).to_bytes() == cc_prv.sign_recoverable(data) - True - >>> pk_hex = eth_prv.public_key.to_hex() - >>> computed_pub = hex2pub(pk_hex) - >>> computed_pub == cc_prv.public_key - True - """ - uncompressed = decode_hex(pub_hex) - if len(uncompressed) == 64: # eth public key format - uncompressed = b"\x04" + uncompressed - - return PublicKey(uncompressed) - - -def hex2prv(prv_hex: str) -> PrivateKey: - """ - Convert ethereum hex to EllipticCurvePrivateKey - - Parameters - ---------- - prv_hex: str - Private key hex string - - Returns - ------- - coincurve.PrivateKey - A secp256k1 private key - - >>> k = generate_eth_key() - >>> sk_hex = k.to_hex() - >>> pk_hex = k.public_key.to_hex() - >>> computed_prv = hex2prv(sk_hex) - >>> computed_prv.to_int() == int(k.to_hex(), 16) - True - """ - return PrivateKey(decode_hex(prv_hex)) - - -def encapsulate(private_key: PrivateKey, peer_public_key: PublicKey) -> bytes: - shared_point = peer_public_key.multiply(private_key.secret) - master = private_key.public_key.format(compressed=False) + shared_point.format( - compressed=False - ) - derived = HKDF(master, AES_KEY_BYTES_LEN, b"", SHA256) - return derived # type: ignore - - -def decapsulate(public_key: PublicKey, peer_private_key: PrivateKey) -> bytes: - shared_point = public_key.multiply(peer_private_key.secret) - master = public_key.format(compressed=False) + shared_point.format(compressed=False) - derived = HKDF(master, AES_KEY_BYTES_LEN, b"", SHA256) - return derived # type: ignore - - -def aes_encrypt(key: bytes, plain_text: bytes) -> bytes: - """ - AES-GCM encryption - - Parameters - ---------- - key: bytes - AES session key, which derived from two secp256k1 keys - plain_text: bytes - Plain text to encrypt - - Returns - ------- - bytes - nonce(16 bytes) + tag(16 bytes) + encrypted data - """ - aes_cipher = AES.new(key, AES_CIPHER_MODE) - - encrypted, tag = aes_cipher.encrypt_and_digest(plain_text) # type: ignore - cipher_text = bytearray() - cipher_text.extend(aes_cipher.nonce) # type: ignore - cipher_text.extend(tag) - cipher_text.extend(encrypted) - return bytes(cipher_text) - - -def aes_decrypt(key: bytes, cipher_text: bytes) -> bytes: - """ - AES-GCM decryption - - Parameters - ---------- - key: bytes - AES session key, which derived from two secp256k1 keys - cipher_text: bytes - Encrypted text: - iv(16 bytes) + tag(16 bytes) + encrypted data - - Returns - ------- - bytes - Plain text - - >>> data = b'this is test data' - >>> key = get_valid_secret() - >>> aes_decrypt(key, aes_encrypt(key, data)) == data - True - >>> import os - >>> key = os.urandom(32) - >>> aes_decrypt(key, aes_encrypt(key, data)) == data - True - """ - iv = cipher_text[:16] - tag = cipher_text[16:32] - ciphered_data = cipher_text[32:] - - # NOTE - # pycryptodome's aes gcm takes nonce as iv - # but actually nonce (12 bytes) should be used to generate iv (16 bytes) and iv should be sequential - # See https://crypto.stackexchange.com/a/71219 - aes_cipher = AES.new(key, AES_CIPHER_MODE, nonce=iv) - return aes_cipher.decrypt_and_verify(ciphered_data, tag) # type: ignore diff --git a/ecies/utils/__init__.py b/ecies/utils/__init__.py new file mode 100644 index 0000000..afec4df --- /dev/null +++ b/ecies/utils/__init__.py @@ -0,0 +1,23 @@ +from .elliptic import ( + decapsulate, + encapsulate, + generate_eth_key, + generate_key, + hex2pk, + hex2sk, +) +from .hex import decode_hex, sha256 +from .symmetric import sym_decrypt, sym_encrypt + +__all__ = [ + "sha256", + "decode_hex", + "sym_encrypt", + "sym_decrypt", + "generate_key", + "generate_eth_key", + "hex2sk", + "hex2pk", + "decapsulate", + "encapsulate", +] diff --git a/ecies/utils/elliptic.py b/ecies/utils/elliptic.py new file mode 100644 index 0000000..bef9fa7 --- /dev/null +++ b/ecies/utils/elliptic.py @@ -0,0 +1,111 @@ +from coincurve import PrivateKey, PublicKey +from coincurve.utils import get_valid_secret +from eth_keys import keys + +from ..config import ECIES_CONFIG +from .hex import decode_hex +from .symmetric import derive_key + + +def generate_key() -> PrivateKey: + """ + Generate a random coincurve.PrivateKey` + + Returns + ------- + coincurve.PrivateKey + A secp256k1 key + + >>> k = generate_key() + """ + return PrivateKey(get_valid_secret()) + + +def generate_eth_key() -> keys.PrivateKey: + """ + Generate a random `eth_keys.keys.PrivateKey` + + Returns + ------- + eth_keys.keys.PrivateKey + An ethereum key + + >>> k = generate_eth_key() + """ + return keys.PrivateKey(get_valid_secret()) + + +def hex2pk(pk_hex: str) -> PublicKey: + """ + Convert ethereum hex to `coincurve.PublicKey` + The hex should be 65 bytes (uncompressed) or 33 bytes (compressed), but ethereum public key has 64 bytes. + `0x04` will be appended if it's an ethereum public key. + + Parameters + ---------- + pk_hex: str + Public key hex string + + Returns + ------- + coincurve.PublicKey + A secp256k1 public key + + >>> from ecies.utils import sha256 + >>> data = b'0' * 32 + >>> data_hash = sha256(data) + >>> eth_sk = generate_eth_key() + >>> cc_sk = hex2sk(eth_sk.to_hex()) + >>> eth_sk.sign_msg_hash(data_hash).to_bytes() == cc_sk.sign_recoverable(data) + True + >>> pk_hex = eth_sk.public_key.to_hex() + >>> computed_pk = hex2pk(pk_hex) + >>> computed_pk == cc_sk.public_key + True + """ + uncompressed = decode_hex(pk_hex) + if len(uncompressed) == 64: # eth public key format + uncompressed = b"\x04" + uncompressed + + return PublicKey(uncompressed) + + +def hex2sk(sk_hex: str) -> PrivateKey: + """ + Convert ethereum hex to `coincurve.PrivateKey` + + Parameters + ---------- + sk_hex: str + Private key hex string + + Returns + ------- + coincurve.PrivateKey + A secp256k1 private key + + >>> k = generate_eth_key() + >>> sk_hex = k.to_hex() + >>> pk_hex = k.public_key.to_hex() + >>> computed_sk = hex2sk(sk_hex) + >>> computed_sk.to_int() == int(k.to_hex(), 16) + True + """ + return PrivateKey(decode_hex(sk_hex)) + + +# private below +def encapsulate(private_key: PrivateKey, peer_public_key: PublicKey) -> bytes: + is_compressed = ECIES_CONFIG.is_hkdf_key_compressed + shared_point = peer_public_key.multiply(private_key.secret) + master = private_key.public_key.format(is_compressed) + shared_point.format( + is_compressed + ) + return derive_key(master) + + +def decapsulate(public_key: PublicKey, peer_private_key: PrivateKey) -> bytes: + is_compressed = ECIES_CONFIG.is_hkdf_key_compressed + shared_point = public_key.multiply(peer_private_key.secret) + master = public_key.format(is_compressed) + shared_point.format(is_compressed) + return derive_key(master) diff --git a/ecies/utils/hex.py b/ecies/utils/hex.py new file mode 100644 index 0000000..35ca2a3 --- /dev/null +++ b/ecies/utils/hex.py @@ -0,0 +1,49 @@ +import codecs +import hashlib + + +def sha256(msg: bytes) -> bytes: + """ + Calculate sha256 hash. + + Parameters + ---------- + msg: bytes + message to hash + + Returns + ------- + bytes + sha256 hash in bytes + + >>> sha256(b'0'*16).hex()[:8] == 'fcdb4b42' + True + """ + return hashlib.sha256(msg).digest() + + +def decode_hex(s: str) -> bytes: + """ + Decode hex string to bytes. `0x` prefix is optional. + + Parameters + ---------- + s: str + hex string + + Returns + ------- + bytes + decoded bytes + + >>> decode_hex('0x7468697320697320612074657374') == b'this is a test' + True + """ + return codecs.decode(remove_0x(s), "hex") + + +# private below +def remove_0x(s: str) -> str: + if s.startswith("0x") or s.startswith("0X"): + return s[2:] + return s diff --git a/ecies/utils/symmetric.py b/ecies/utils/symmetric.py new file mode 100644 index 0000000..8fe3a23 --- /dev/null +++ b/ecies/utils/symmetric.py @@ -0,0 +1,108 @@ +import os + +from Crypto.Cipher import AES, ChaCha20_Poly1305 +from Crypto.Hash import SHA256 +from Crypto.Protocol.KDF import HKDF + +from ..config import ECIES_CONFIG + +AES_CIPHER_MODE = AES.MODE_GCM +AEAD_TAG_LENGTH = 16 +XCHACHA20_NONCE_LENGTH = 24 + + +def sym_encrypt(key: bytes, plain_text: bytes) -> bytes: + """ + Symmetric encryption. AES-256-GCM or XChaCha20-Poly1305. + + Nonce may be 12/16 bytes on AES, 24 bytes on XChaCha. Default is AES-256-GCM with 16 bytes nonce. + + Parameters + ---------- + key: bytes + Symmetric encryption session key, which derived from two secp256k1 keys + plain_text: bytes + Plain text to encrypt + + Returns + ------- + bytes + nonce + tag(16 bytes) + encrypted data + """ + algorithm = ECIES_CONFIG.symmetric_algorithm + if algorithm == "aes-256-gcm": + nonce_length = ECIES_CONFIG.symmetric_nonce_length + nonce = os.urandom(nonce_length) + cipher = AES.new(key, AES_CIPHER_MODE, nonce) + elif algorithm == "xchacha20": + nonce = os.urandom(XCHACHA20_NONCE_LENGTH) + cipher = ChaCha20_Poly1305.new(key=key, nonce=nonce) # type:ignore + else: + raise NotImplementedError + + encrypted, tag = cipher.encrypt_and_digest(plain_text) + cipher_text = bytearray() + cipher_text.extend(nonce) + cipher_text.extend(tag) + cipher_text.extend(encrypted) + return bytes(cipher_text) + + +def sym_decrypt(key: bytes, cipher_text: bytes) -> bytes: + """ + AES-GCM decryption. AES-256-GCM or XChaCha20-Poly1305. + + Parameters + ---------- + key: bytes + Symmetric encryption session key, which derived from two secp256k1 keys + cipher_text: bytes + Encrypted text: + nonce + tag(16 bytes) + encrypted data + + Returns + ------- + bytes + Plain text + + >>> from coincurve.utils import get_valid_secret + >>> data = b'this is test data' + >>> key = get_valid_secret() + >>> sym_decrypt(key, sym_encrypt(key, data)) == data + True + >>> import os + >>> key = os.urandom(32) + >>> sym_decrypt(key, sym_encrypt(key, data)) == data + True + """ + + # NOTE + # pycryptodome's aes gcm takes nonce as iv + # but actually nonce (12 bytes) should be used to generate iv (16 bytes) and iv should be sequential + # See https://crypto.stackexchange.com/a/71219 + # You can configure to use 12 bytes nonce by setting `ECIES_CONFIG.symmetric_nonce_length = 12` + # If it's 12 bytes, the nonce can be incremented by 1 for each encryption + # If it's 16 bytes, the nonce will be used to hash, so it's meaningless to increment + + algorithm = ECIES_CONFIG.symmetric_algorithm + if algorithm == "aes-256-gcm": + nonce_length = ECIES_CONFIG.symmetric_nonce_length + nonce_tag_length = nonce_length + AEAD_TAG_LENGTH + nonce = cipher_text[:nonce_length] + tag = cipher_text[nonce_length:nonce_tag_length] + ciphered_data = cipher_text[nonce_tag_length:] + cipher = AES.new(key, AES_CIPHER_MODE, nonce) + elif algorithm == "xchacha20": + nonce_tag_length = XCHACHA20_NONCE_LENGTH + AEAD_TAG_LENGTH + nonce = cipher_text[:XCHACHA20_NONCE_LENGTH] + tag = cipher_text[XCHACHA20_NONCE_LENGTH:nonce_tag_length] + ciphered_data = cipher_text[nonce_tag_length:] + cipher = ChaCha20_Poly1305.new(key=key, nonce=nonce) # type:ignore + else: + raise NotImplementedError + return cipher.decrypt_and_verify(ciphered_data, tag) + + +def derive_key(master: bytes) -> bytes: + derived = HKDF(master, 32, b"", SHA256) + return derived # type: ignore diff --git a/poetry.lock b/poetry.lock index 9221c5f..2443607 100644 --- a/poetry.lock +++ b/poetry.lock @@ -96,17 +96,6 @@ d = ["aiohttp (>=3.7.4)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] -[[package]] -name = "cached-property" -version = "1.5.2" -description = "A decorator for caching properties in classes." -optional = false -python-versions = "*" -files = [ - {file = "cached-property-1.5.2.tar.gz", hash = "sha256:9fa5755838eecbb2d234c3aa390bd80fbd3ac6b6869109bfc1b499f7bd89a130"}, - {file = "cached_property-1.5.2-py2.py3-none-any.whl", hash = "sha256:df4f613cf7ad9a588cc381aaf4a512d26265ecebd5eb9e1ba12f1319eb85a6a0"}, -] - [[package]] name = "cffi" version = "1.15.1" @@ -263,6 +252,81 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "coverage" +version = "7.2.7" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "coverage-7.2.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d39b5b4f2a66ccae8b7263ac3c8170994b65266797fb96cbbfd3fb5b23921db8"}, + {file = "coverage-7.2.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6d040ef7c9859bb11dfeb056ff5b3872436e3b5e401817d87a31e1750b9ae2fb"}, + {file = "coverage-7.2.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba90a9563ba44a72fda2e85302c3abc71c5589cea608ca16c22b9804262aaeb6"}, + {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7d9405291c6928619403db1d10bd07888888ec1abcbd9748fdaa971d7d661b2"}, + {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31563e97dae5598556600466ad9beea39fb04e0229e61c12eaa206e0aa202063"}, + {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ebba1cd308ef115925421d3e6a586e655ca5a77b5bf41e02eb0e4562a111f2d1"}, + {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cb017fd1b2603ef59e374ba2063f593abe0fc45f2ad9abdde5b4d83bd922a353"}, + {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d62a5c7dad11015c66fbb9d881bc4caa5b12f16292f857842d9d1871595f4495"}, + {file = "coverage-7.2.7-cp310-cp310-win32.whl", hash = "sha256:ee57190f24fba796e36bb6d3aa8a8783c643d8fa9760c89f7a98ab5455fbf818"}, + {file = "coverage-7.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:f75f7168ab25dd93110c8a8117a22450c19976afbc44234cbf71481094c1b850"}, + {file = "coverage-7.2.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06a9a2be0b5b576c3f18f1a241f0473575c4a26021b52b2a85263a00f034d51f"}, + {file = "coverage-7.2.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5baa06420f837184130752b7c5ea0808762083bf3487b5038d68b012e5937dbe"}, + {file = "coverage-7.2.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdec9e8cbf13a5bf63290fc6013d216a4c7232efb51548594ca3631a7f13c3a3"}, + {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:52edc1a60c0d34afa421c9c37078817b2e67a392cab17d97283b64c5833f427f"}, + {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63426706118b7f5cf6bb6c895dc215d8a418d5952544042c8a2d9fe87fcf09cb"}, + {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:afb17f84d56068a7c29f5fa37bfd38d5aba69e3304af08ee94da8ed5b0865833"}, + {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:48c19d2159d433ccc99e729ceae7d5293fbffa0bdb94952d3579983d1c8c9d97"}, + {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0e1f928eaf5469c11e886fe0885ad2bf1ec606434e79842a879277895a50942a"}, + {file = "coverage-7.2.7-cp311-cp311-win32.whl", hash = "sha256:33d6d3ea29d5b3a1a632b3c4e4f4ecae24ef170b0b9ee493883f2df10039959a"}, + {file = "coverage-7.2.7-cp311-cp311-win_amd64.whl", hash = "sha256:5b7540161790b2f28143191f5f8ec02fb132660ff175b7747b95dcb77ac26562"}, + {file = "coverage-7.2.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f2f67fe12b22cd130d34d0ef79206061bfb5eda52feb6ce0dba0644e20a03cf4"}, + {file = "coverage-7.2.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a342242fe22407f3c17f4b499276a02b01e80f861f1682ad1d95b04018e0c0d4"}, + {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:171717c7cb6b453aebac9a2ef603699da237f341b38eebfee9be75d27dc38e01"}, + {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49969a9f7ffa086d973d91cec8d2e31080436ef0fb4a359cae927e742abfaaa6"}, + {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b46517c02ccd08092f4fa99f24c3b83d8f92f739b4657b0f146246a0ca6a831d"}, + {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:a3d33a6b3eae87ceaefa91ffdc130b5e8536182cd6dfdbfc1aa56b46ff8c86de"}, + {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:976b9c42fb2a43ebf304fa7d4a310e5f16cc99992f33eced91ef6f908bd8f33d"}, + {file = "coverage-7.2.7-cp312-cp312-win32.whl", hash = "sha256:8de8bb0e5ad103888d65abef8bca41ab93721647590a3f740100cd65c3b00511"}, + {file = "coverage-7.2.7-cp312-cp312-win_amd64.whl", hash = "sha256:9e31cb64d7de6b6f09702bb27c02d1904b3aebfca610c12772452c4e6c21a0d3"}, + {file = "coverage-7.2.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:58c2ccc2f00ecb51253cbe5d8d7122a34590fac9646a960d1430d5b15321d95f"}, + {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d22656368f0e6189e24722214ed8d66b8022db19d182927b9a248a2a8a2f67eb"}, + {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a895fcc7b15c3fc72beb43cdcbdf0ddb7d2ebc959edac9cef390b0d14f39f8a9"}, + {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e84606b74eb7de6ff581a7915e2dab7a28a0517fbe1c9239eb227e1354064dcd"}, + {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:0a5f9e1dbd7fbe30196578ca36f3fba75376fb99888c395c5880b355e2875f8a"}, + {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:419bfd2caae268623dd469eff96d510a920c90928b60f2073d79f8fe2bbc5959"}, + {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2aee274c46590717f38ae5e4650988d1af340fe06167546cc32fe2f58ed05b02"}, + {file = "coverage-7.2.7-cp37-cp37m-win32.whl", hash = "sha256:61b9a528fb348373c433e8966535074b802c7a5d7f23c4f421e6c6e2f1697a6f"}, + {file = "coverage-7.2.7-cp37-cp37m-win_amd64.whl", hash = "sha256:b1c546aca0ca4d028901d825015dc8e4d56aac4b541877690eb76490f1dc8ed0"}, + {file = "coverage-7.2.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:54b896376ab563bd38453cecb813c295cf347cf5906e8b41d340b0321a5433e5"}, + {file = "coverage-7.2.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3d376df58cc111dc8e21e3b6e24606b5bb5dee6024f46a5abca99124b2229ef5"}, + {file = "coverage-7.2.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e330fc79bd7207e46c7d7fd2bb4af2963f5f635703925543a70b99574b0fea9"}, + {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e9d683426464e4a252bf70c3498756055016f99ddaec3774bf368e76bbe02b6"}, + {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d13c64ee2d33eccf7437961b6ea7ad8673e2be040b4f7fd4fd4d4d28d9ccb1e"}, + {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b7aa5f8a41217360e600da646004f878250a0d6738bcdc11a0a39928d7dc2050"}, + {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8fa03bce9bfbeeef9f3b160a8bed39a221d82308b4152b27d82d8daa7041fee5"}, + {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:245167dd26180ab4c91d5e1496a30be4cd721a5cf2abf52974f965f10f11419f"}, + {file = "coverage-7.2.7-cp38-cp38-win32.whl", hash = "sha256:d2c2db7fd82e9b72937969bceac4d6ca89660db0a0967614ce2481e81a0b771e"}, + {file = "coverage-7.2.7-cp38-cp38-win_amd64.whl", hash = "sha256:2e07b54284e381531c87f785f613b833569c14ecacdcb85d56b25c4622c16c3c"}, + {file = "coverage-7.2.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:537891ae8ce59ef63d0123f7ac9e2ae0fc8b72c7ccbe5296fec45fd68967b6c9"}, + {file = "coverage-7.2.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:06fb182e69f33f6cd1d39a6c597294cff3143554b64b9825d1dc69d18cc2fff2"}, + {file = "coverage-7.2.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:201e7389591af40950a6480bd9edfa8ed04346ff80002cec1a66cac4549c1ad7"}, + {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f6951407391b639504e3b3be51b7ba5f3528adbf1a8ac3302b687ecababf929e"}, + {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f48351d66575f535669306aa7d6d6f71bc43372473b54a832222803eb956fd1"}, + {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b29019c76039dc3c0fd815c41392a044ce555d9bcdd38b0fb60fb4cd8e475ba9"}, + {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:81c13a1fc7468c40f13420732805a4c38a105d89848b7c10af65a90beff25250"}, + {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:975d70ab7e3c80a3fe86001d8751f6778905ec723f5b110aed1e450da9d4b7f2"}, + {file = "coverage-7.2.7-cp39-cp39-win32.whl", hash = "sha256:7ee7d9d4822c8acc74a5e26c50604dff824710bc8de424904c0982e25c39c6cb"}, + {file = "coverage-7.2.7-cp39-cp39-win_amd64.whl", hash = "sha256:eb393e5ebc85245347950143969b241d08b52b88a3dc39479822e073a1a8eb27"}, + {file = "coverage-7.2.7-pp37.pp38.pp39-none-any.whl", hash = "sha256:b7b4c971f05e6ae490fef852c218b0e79d4e52f79ef0c8475566584a8fb3e01d"}, + {file = "coverage-7.2.7.tar.gz", hash = "sha256:924d94291ca674905fe9481f12294eb11f2d3d3fd1adb20314ba89e94f44ed59"}, +] + +[package.dependencies] +tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} + +[package.extras] +toml = ["tomli"] + [[package]] name = "cytoolz" version = "0.12.2" @@ -452,7 +516,6 @@ files = [ ] [package.dependencies] -cached-property = {version = ">=1.5.2,<2", markers = "python_version < \"3.8\""} cytoolz = {version = ">=0.10.1", markers = "implementation_name == \"cpython\""} eth-hash = ">=0.3.1" eth-typing = ">=3.0.0" @@ -464,6 +527,20 @@ doc = ["sphinx (>=5.0.0)", "sphinx-rtd-theme (>=1.0.0)", "towncrier (>=21,<22)"] lint = ["black (>=23)", "flake8 (==3.8.3)", "isort (>=5.11.0)", "mypy (==0.971)", "pydocstyle (>=5.0.0)", "types-setuptools"] test = ["hypothesis (>=4.43.0)", "mypy (==0.971)", "pytest (>=7.0.0)", "pytest-xdist (>=2.4.0)", "types-setuptools"] +[[package]] +name = "exceptiongroup" +version = "1.1.2" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.1.2-py3-none-any.whl", hash = "sha256:e346e69d186172ca7cf029c8c1d16235aa0e04035e5750b4b95039e65204328f"}, + {file = "exceptiongroup-1.1.2.tar.gz", hash = "sha256:12c3e887d6485d16943a309616de20ae5582633e0a2eda17f4e10fd61c1e8af5"}, +] + +[package.extras] +test = ["pytest (>=6)"] + [[package]] name = "executing" version = "1.2.0" @@ -480,19 +557,30 @@ tests = ["asttokens", "littleutils", "pytest", "rich"] [[package]] name = "flake8" -version = "6.0.0" +version = "6.1.0" description = "the modular source code checker: pep8 pyflakes and co" optional = false python-versions = ">=3.8.1" files = [ - {file = "flake8-6.0.0-py2.py3-none-any.whl", hash = "sha256:3833794e27ff64ea4e9cf5d410082a8b97ff1a06c16aa3d2027339cd0f1195c7"}, - {file = "flake8-6.0.0.tar.gz", hash = "sha256:c61007e76655af75e6785a931f452915b371dc48f56efd765247c8fe68f2b181"}, + {file = "flake8-6.1.0-py2.py3-none-any.whl", hash = "sha256:ffdfce58ea94c6580c77888a86506937f9a1a227dfcd15f245d694ae20a6b6e5"}, + {file = "flake8-6.1.0.tar.gz", hash = "sha256:d5b3857f07c030bdb5bf41c7f53799571d75c4491748a3adcd47de929e34cd23"}, ] [package.dependencies] mccabe = ">=0.7.0,<0.8.0" -pycodestyle = ">=2.10.0,<2.11.0" -pyflakes = ">=3.0.0,<3.1.0" +pycodestyle = ">=2.11.0,<2.12.0" +pyflakes = ">=3.1.0,<3.2.0" + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] [[package]] name = "ipython" @@ -535,21 +623,21 @@ test-extra = ["curio", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.21)", "pa [[package]] name = "jedi" -version = "0.18.2" +version = "0.19.0" description = "An autocompletion tool for Python that can be used for text editors." optional = false python-versions = ">=3.6" files = [ - {file = "jedi-0.18.2-py2.py3-none-any.whl", hash = "sha256:203c1fd9d969ab8f2119ec0a3342e0b49910045abe6af0a3ae83a5764d54639e"}, - {file = "jedi-0.18.2.tar.gz", hash = "sha256:bae794c30d07f6d910d32a7048af09b5a39ed740918da923c6b780790ebac612"}, + {file = "jedi-0.19.0-py2.py3-none-any.whl", hash = "sha256:cb8ce23fbccff0025e9386b5cf85e892f94c9b822378f8da49970471335ac64e"}, + {file = "jedi-0.19.0.tar.gz", hash = "sha256:bcf9894f1753969cbac8022a8c2eaee06bfa3724e4192470aaffe7eb6272b0c4"}, ] [package.dependencies] -parso = ">=0.8.0,<0.9.0" +parso = ">=0.8.3,<0.9.0" [package.extras] docs = ["Jinja2 (==2.11.3)", "MarkupSafe (==1.1.1)", "Pygments (==2.8.1)", "alabaster (==0.7.12)", "babel (==2.9.1)", "chardet (==4.0.0)", "commonmark (==0.8.1)", "docutils (==0.17.1)", "future (==0.18.2)", "idna (==2.10)", "imagesize (==1.2.0)", "mock (==1.0.1)", "packaging (==20.9)", "pyparsing (==2.4.7)", "pytz (==2021.1)", "readthedocs-sphinx-ext (==2.1.4)", "recommonmark (==0.5.0)", "requests (==2.25.1)", "six (==1.15.0)", "snowballstemmer (==2.1.0)", "sphinx (==1.8.5)", "sphinx-rtd-theme (==0.4.3)", "sphinxcontrib-serializinghtml (==1.1.4)", "sphinxcontrib-websupport (==1.2.4)", "urllib3 (==1.26.4)"] -qa = ["flake8 (==3.8.3)", "mypy (==0.782)"] +qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] testing = ["Django (<3.1)", "attrs", "colorama", "docopt", "pytest (<7.0.0)"] [[package]] @@ -615,7 +703,6 @@ files = [ [package.dependencies] mypy-extensions = ">=1.0.0" tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typed-ast = {version = ">=1.4.0,<2", markers = "python_version < \"3.8\""} typing-extensions = ">=4.1.0" [package.extras] @@ -663,13 +750,13 @@ testing = ["docopt", "pytest (<6.0.0)"] [[package]] name = "pathspec" -version = "0.11.1" +version = "0.11.2" description = "Utility library for gitignore style pattern matching of file paths." optional = false python-versions = ">=3.7" files = [ - {file = "pathspec-0.11.1-py3-none-any.whl", hash = "sha256:d8af70af76652554bd134c22b3e8a1cc46ed7d91edcdd721ef1a0c51a84a5293"}, - {file = "pathspec-0.11.1.tar.gz", hash = "sha256:2798de800fa92780e33acca925945e9a19a133b715067cf165b8866c15a31687"}, + {file = "pathspec-0.11.2-py3-none-any.whl", hash = "sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20"}, + {file = "pathspec-0.11.2.tar.gz", hash = "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3"}, ] [[package]] @@ -699,18 +786,33 @@ files = [ [[package]] name = "platformdirs" -version = "3.9.1" +version = "3.10.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." optional = false python-versions = ">=3.7" files = [ - {file = "platformdirs-3.9.1-py3-none-any.whl", hash = "sha256:ad8291ae0ae5072f66c16945166cb11c63394c7a3ad1b1bc9828ca3162da8c2f"}, - {file = "platformdirs-3.9.1.tar.gz", hash = "sha256:1b42b450ad933e981d56e59f1b97495428c9bd60698baab9f3eb3d00d5822421"}, + {file = "platformdirs-3.10.0-py3-none-any.whl", hash = "sha256:d7c24979f292f916dc9cbf8648319032f551ea8c49a4c9bf2fb556a02070ec1d"}, + {file = "platformdirs-3.10.0.tar.gz", hash = "sha256:b45696dab2d7cc691a3226759c0d3b00c47c8b6e293d96f6436f733303f77f6d"}, ] [package.extras] -docs = ["furo (>=2023.5.20)", "proselint (>=0.13)", "sphinx (>=7.0.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.3.1)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)"] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] + +[[package]] +name = "pluggy" +version = "1.2.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pluggy-1.2.0-py3-none-any.whl", hash = "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849"}, + {file = "pluggy-1.2.0.tar.gz", hash = "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] [[package]] name = "prompt-toolkit" @@ -753,13 +855,13 @@ tests = ["pytest"] [[package]] name = "pycodestyle" -version = "2.10.0" +version = "2.11.0" description = "Python style guide checker" optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" files = [ - {file = "pycodestyle-2.10.0-py2.py3-none-any.whl", hash = "sha256:8a4eaf0d0495c7395bdab3589ac2db602797d76207242c17d470186815706610"}, - {file = "pycodestyle-2.10.0.tar.gz", hash = "sha256:347187bdb476329d98f695c213d7295a846d1152ff4fe9bacb8a9590b8ee7053"}, + {file = "pycodestyle-2.11.0-py2.py3-none-any.whl", hash = "sha256:5d1013ba8dc7895b548be5afb05740ca82454fd899971563d2ef625d090326f8"}, + {file = "pycodestyle-2.11.0.tar.gz", hash = "sha256:259bcc17857d8a8b3b4a2327324b79e5f020a13c16074670f9c8c8f872ea76d0"}, ] [[package]] @@ -816,13 +918,13 @@ files = [ [[package]] name = "pyflakes" -version = "3.0.1" +version = "3.1.0" description = "passive checker of Python programs" optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" files = [ - {file = "pyflakes-3.0.1-py2.py3-none-any.whl", hash = "sha256:ec55bf7fe21fff7f1ad2f7da62363d749e2a470500eab1b555334b67aa1ef8cf"}, - {file = "pyflakes-3.0.1.tar.gz", hash = "sha256:ec8b276a6b60bd80defed25add7e439881c19e64850afd9b346283d4165fd0fd"}, + {file = "pyflakes-3.1.0-py2.py3-none-any.whl", hash = "sha256:4132f6d49cb4dae6819e5379898f2b8cce3c5f23994194c24b77d5da2e36f774"}, + {file = "pyflakes-3.1.0.tar.gz", hash = "sha256:a0aae034c444db0071aa077972ba4768d40c830d9539fd45bf4cd3f8f6992efc"}, ] [[package]] @@ -839,6 +941,46 @@ files = [ [package.extras] plugins = ["importlib-metadata"] +[[package]] +name = "pytest" +version = "7.4.0" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-7.4.0-py3-none-any.whl", hash = "sha256:78bf16451a2eb8c7a2ea98e32dc119fd2aa758f1d5d66dbf0a59d69a3969df32"}, + {file = "pytest-7.4.0.tar.gz", hash = "sha256:b4bf8c45bd59934ed84001ad51e11b4ee40d40a1229d2c79f9c592b0a3f6bd8a"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} + +[package.extras] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-cov" +version = "4.1.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, + {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, +] + +[package.dependencies] +coverage = {version = ">=5.2.1", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] + [[package]] name = "six" version = "1.16.0" @@ -906,56 +1048,6 @@ files = [ docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] test = ["argcomplete (>=2.0)", "pre-commit", "pytest", "pytest-mock"] -[[package]] -name = "typed-ast" -version = "1.5.5" -description = "a fork of Python 2 and 3 ast modules with type comment support" -optional = false -python-versions = ">=3.6" -files = [ - {file = "typed_ast-1.5.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4bc1efe0ce3ffb74784e06460f01a223ac1f6ab31c6bc0376a21184bf5aabe3b"}, - {file = "typed_ast-1.5.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5f7a8c46a8b333f71abd61d7ab9255440d4a588f34a21f126bbfc95f6049e686"}, - {file = "typed_ast-1.5.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:597fc66b4162f959ee6a96b978c0435bd63791e31e4f410622d19f1686d5e769"}, - {file = "typed_ast-1.5.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d41b7a686ce653e06c2609075d397ebd5b969d821b9797d029fccd71fdec8e04"}, - {file = "typed_ast-1.5.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:5fe83a9a44c4ce67c796a1b466c270c1272e176603d5e06f6afbc101a572859d"}, - {file = "typed_ast-1.5.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d5c0c112a74c0e5db2c75882a0adf3133adedcdbfd8cf7c9d6ed77365ab90a1d"}, - {file = "typed_ast-1.5.5-cp310-cp310-win_amd64.whl", hash = "sha256:e1a976ed4cc2d71bb073e1b2a250892a6e968ff02aa14c1f40eba4f365ffec02"}, - {file = "typed_ast-1.5.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c631da9710271cb67b08bd3f3813b7af7f4c69c319b75475436fcab8c3d21bee"}, - {file = "typed_ast-1.5.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b445c2abfecab89a932b20bd8261488d574591173d07827c1eda32c457358b18"}, - {file = "typed_ast-1.5.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc95ffaaab2be3b25eb938779e43f513e0e538a84dd14a5d844b8f2932593d88"}, - {file = "typed_ast-1.5.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61443214d9b4c660dcf4b5307f15c12cb30bdfe9588ce6158f4a005baeb167b2"}, - {file = "typed_ast-1.5.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6eb936d107e4d474940469e8ec5b380c9b329b5f08b78282d46baeebd3692dc9"}, - {file = "typed_ast-1.5.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e48bf27022897577d8479eaed64701ecaf0467182448bd95759883300ca818c8"}, - {file = "typed_ast-1.5.5-cp311-cp311-win_amd64.whl", hash = "sha256:83509f9324011c9a39faaef0922c6f720f9623afe3fe220b6d0b15638247206b"}, - {file = "typed_ast-1.5.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:44f214394fc1af23ca6d4e9e744804d890045d1643dd7e8229951e0ef39429b5"}, - {file = "typed_ast-1.5.5-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:118c1ce46ce58fda78503eae14b7664163aa735b620b64b5b725453696f2a35c"}, - {file = "typed_ast-1.5.5-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4919b808efa61101456e87f2d4c75b228f4e52618621c77f1ddcaae15904fa"}, - {file = "typed_ast-1.5.5-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:fc2b8c4e1bc5cd96c1a823a885e6b158f8451cf6f5530e1829390b4d27d0807f"}, - {file = "typed_ast-1.5.5-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:16f7313e0a08c7de57f2998c85e2a69a642e97cb32f87eb65fbfe88381a5e44d"}, - {file = "typed_ast-1.5.5-cp36-cp36m-win_amd64.whl", hash = "sha256:2b946ef8c04f77230489f75b4b5a4a6f24c078be4aed241cfabe9cbf4156e7e5"}, - {file = "typed_ast-1.5.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2188bc33d85951ea4ddad55d2b35598b2709d122c11c75cffd529fbc9965508e"}, - {file = "typed_ast-1.5.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0635900d16ae133cab3b26c607586131269f88266954eb04ec31535c9a12ef1e"}, - {file = "typed_ast-1.5.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:57bfc3cf35a0f2fdf0a88a3044aafaec1d2f24d8ae8cd87c4f58d615fb5b6311"}, - {file = "typed_ast-1.5.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:fe58ef6a764de7b4b36edfc8592641f56e69b7163bba9f9c8089838ee596bfb2"}, - {file = "typed_ast-1.5.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d09d930c2d1d621f717bb217bf1fe2584616febb5138d9b3e8cdd26506c3f6d4"}, - {file = "typed_ast-1.5.5-cp37-cp37m-win_amd64.whl", hash = "sha256:d40c10326893ecab8a80a53039164a224984339b2c32a6baf55ecbd5b1df6431"}, - {file = "typed_ast-1.5.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:fd946abf3c31fb50eee07451a6aedbfff912fcd13cf357363f5b4e834cc5e71a"}, - {file = "typed_ast-1.5.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ed4a1a42df8a3dfb6b40c3d2de109e935949f2f66b19703eafade03173f8f437"}, - {file = "typed_ast-1.5.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:045f9930a1550d9352464e5149710d56a2aed23a2ffe78946478f7b5416f1ede"}, - {file = "typed_ast-1.5.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:381eed9c95484ceef5ced626355fdc0765ab51d8553fec08661dce654a935db4"}, - {file = "typed_ast-1.5.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:bfd39a41c0ef6f31684daff53befddae608f9daf6957140228a08e51f312d7e6"}, - {file = "typed_ast-1.5.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8c524eb3024edcc04e288db9541fe1f438f82d281e591c548903d5b77ad1ddd4"}, - {file = "typed_ast-1.5.5-cp38-cp38-win_amd64.whl", hash = "sha256:7f58fabdde8dcbe764cef5e1a7fcb440f2463c1bbbec1cf2a86ca7bc1f95184b"}, - {file = "typed_ast-1.5.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:042eb665ff6bf020dd2243307d11ed626306b82812aba21836096d229fdc6a10"}, - {file = "typed_ast-1.5.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:622e4a006472b05cf6ef7f9f2636edc51bda670b7bbffa18d26b255269d3d814"}, - {file = "typed_ast-1.5.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1efebbbf4604ad1283e963e8915daa240cb4bf5067053cf2f0baadc4d4fb51b8"}, - {file = "typed_ast-1.5.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0aefdd66f1784c58f65b502b6cf8b121544680456d1cebbd300c2c813899274"}, - {file = "typed_ast-1.5.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:48074261a842acf825af1968cd912f6f21357316080ebaca5f19abbb11690c8a"}, - {file = "typed_ast-1.5.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:429ae404f69dc94b9361bb62291885894b7c6fb4640d561179548c849f8492ba"}, - {file = "typed_ast-1.5.5-cp39-cp39-win_amd64.whl", hash = "sha256:335f22ccb244da2b5c296e6f96b06ee9bed46526db0de38d2f0e5a6597b81155"}, - {file = "typed_ast-1.5.5.tar.gz", hash = "sha256:94282f7a354f36ef5dbce0ef3467ebf6a258e370ab33d5b40c249fa996e590dd"}, -] - [[package]] name = "typing-extensions" version = "4.7.1" @@ -980,5 +1072,5 @@ files = [ [metadata] lock-version = "2.0" -python-versions = "^3.7.2" -content-hash = "9e792624757bc5f5183b737bf6728308ead482d963081ba04ab8a0daf982e8c6" +python-versions = "^3.8" +content-hash = "1cb351cf1dac9317996368eea5ed7fe63556a825732e2811f3c469bce469e83d" diff --git a/pyproject.toml b/pyproject.toml index d6b818c..11a9890 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "eciespy" packages = [ {include = "ecies"}, ] -version = "0.3.13" +version = "0.4.0" # docs authors = ["Weiliang Li "] description = "Elliptic Curve Integrated Encryption Scheme for secp256k1 in Python" @@ -33,18 +33,20 @@ keywords = [ include = ["ecies/py.typed"] [tool.poetry.dependencies] -python = "^3.7.2" +python = "^3.8" # 3rd party coincurve = ">=13,<19" eth-keys = "^0.4.0" -pycryptodome = "^3.15.0" +pycryptodome = "^3.18.0" [tool.poetry.group.dev.dependencies] black = {version = "^23.7", python = "^3.9"} flake8 = {version = "^6.0.0", python = "^3.9"} ipython = {version = "^8.14.0", python = "^3.9"} mypy = "^1.4.1" +pytest = "^7.4.0" +pytest-cov = "^4.1.0" [tool.poetry.scripts] eciespy = "ecies.__main__:main" @@ -52,3 +54,10 @@ eciespy = "ecies.__main__:main" [build-system] build-backend = "poetry.core.masonry.api" requires = ["poetry-core>=1.0.0"] + +[tool.isort] +multi_line_output = 3 +profile = "black" + +[tool.pytest.ini_options] +addopts = "--doctest-modules" diff --git a/scripts/ci.sh b/scripts/ci.sh index 458be90..20d6c53 100755 --- a/scripts/ci.sh +++ b/scripts/ci.sh @@ -1,17 +1,10 @@ #!/bin/sh -# test -poetry run pip install coverage -poetry run python -m doctest -v ecies/utils.py -poetry run coverage run -m unittest discover . -poetry run coverage report -poetry run coverage xml - # test cli poetry run eciespy -h poetry run eciespy -g -echo '0x95d3c5e483e9b1d4f5fc8e79b2deaf51362980de62dbb082a9a4257eef653d7d' > prv -echo '0x98afe4f150642cd05cc9d2fa36458ce0a58567daeaf5fde7333ba9b403011140a4e28911fcf83ab1f457a30b4959efc4b9306f514a4c3711a16a80e3b47eb58b' > pub -echo 'helloworld' | poetry run eciespy -e -k pub -O out -poetry run eciespy -d -k prv -D out -rm prv pub out +echo '0x95d3c5e483e9b1d4f5fc8e79b2deaf51362980de62dbb082a9a4257eef653d7d' > sk +echo '0x98afe4f150642cd05cc9d2fa36458ce0a58567daeaf5fde7333ba9b403011140a4e28911fcf83ab1f457a30b4959efc4b9306f514a4c3711a16a80e3b47eb58b' > pk +echo 'hello ecies' | poetry run eciespy -e -k pk -O out +poetry run eciespy -d -k sk -D out +rm sk pk out diff --git a/setup.cfg b/setup.cfg index 43927b6..d07b120 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,4 +1,5 @@ [flake8] -ignore = E501, W605 +ignore = E501, W605, E203, W503 exclude = .git,__pycache__,docs/source/conf.py,old,build,dist,venv max-complexity = 10 +max-line-length = 88 diff --git a/tests/test_crypt.py b/tests/test_crypt.py index ba468f4..a34d656 100644 --- a/tests/test_crypt.py +++ b/tests/test_crypt.py @@ -1,75 +1,64 @@ -import os -import unittest - +import pytest from coincurve import PrivateKey -from Crypto.Protocol.KDF import HKDF -from Crypto.Hash import SHA256 - -from ecies import encrypt, decrypt -from ecies.utils import ( - sha256, - encapsulate, - decapsulate, - generate_eth_key, - generate_key, - aes_encrypt, - aes_decrypt, -) - - -class TestCrypt(unittest.TestCase): - def setUp(self): - self.test_string = b"this is a test" - self.big_data = b"0" * 1024 * 1024 * 100 # 100 MB - - def test_hash(self): - self.assertEqual(sha256(b"0" * 16).hex()[:8], "fcdb4b42") - - def test_hdkf(self): - derived = HKDF(b"secret", 32, b"", SHA256).hex() - self.assertEqual( - derived, "2f34e5ff91ec85d53ca9b543683174d0cf550b60d5f52b24c97b386cfcf6cbbf" - ) - - k1 = PrivateKey(secret=bytes([2])) - self.assertEqual(k1.to_int(), 2) - - k2 = PrivateKey(secret=bytes([3])) - self.assertEqual(k2.to_int(), 3) - - self.assertEqual(encapsulate(k1, k2.public_key), decapsulate(k1.public_key, k2)) - self.assertEqual( - encapsulate(k1, k2.public_key).hex(), - "6f982d63e8590c9d9b5b4c1959ff80315d772edd8f60287c9361d548d5200f82", - ) - - def test_elliptic(self): - data = self.test_string - k = generate_eth_key() - sk_hex = k.to_hex() +from eth_keys import keys + +from ecies import ECIES_CONFIG, decrypt, encrypt +from ecies.utils import generate_eth_key, generate_key +from ecies.utils.hex import decode_hex + +data = b"this is a test" + + +def __check(k, compressed=False): + sk_hex = k.to_hex() + if isinstance(k, PrivateKey): + pk_hex = k.public_key.format(compressed).hex() + elif isinstance(k, keys.PrivateKey): pk_hex = k.public_key.to_hex() - self.assertEqual(data, decrypt(sk_hex, encrypt(pk_hex, data))) - - k = generate_key() - sk_hex = k.to_hex() - pk_hex = k.public_key.format(False).hex() - self.assertEqual(data, decrypt(sk_hex, encrypt(pk_hex, data))) - self.assertEqual( - data, decrypt(bytes.fromhex(sk_hex), encrypt(bytes.fromhex(pk_hex), data)) - ) - - k = generate_key() - sk_hex = k.to_hex() - pk_hex = k.public_key.format(True).hex() - self.assertEqual(data, decrypt(sk_hex, encrypt(pk_hex, data))) - self.assertEqual( - data, decrypt(bytes.fromhex(sk_hex), encrypt(bytes.fromhex(pk_hex), data)) - ) - - self.assertRaises(TypeError, encrypt, 1, data) - self.assertRaises(TypeError, decrypt, 1, encrypt(bytes.fromhex(pk_hex), data)) - - def test_aes(self): - data = self.big_data - key = os.urandom(16) - self.assertEqual(data, aes_decrypt(key, aes_encrypt(key, data))) + else: + raise NotImplementedError + assert data == decrypt(sk_hex, encrypt(pk_hex, data)) + sk_bytes = decode_hex(sk_hex) + pk_bytes = decode_hex(pk_hex) + if len(pk_bytes) == 64: # eth + pk_bytes = b"\x04" + pk_bytes + assert data == decrypt(sk_bytes, encrypt(pk_bytes, data)) + + +def test_elliptic(): + __check(generate_eth_key()) + __check(generate_key()) + __check(generate_key(), True) + + with pytest.raises(TypeError): + encrypt(1, data) + + k = generate_key() + pk_hex = k.public_key.format(True).hex() + + with pytest.raises(TypeError): + decrypt(1, encrypt(bytes.fromhex(pk_hex), data)) + + +def test_hkdf_config(): + ECIES_CONFIG.is_hkdf_key_compressed = True + __check(generate_key()) + ECIES_CONFIG.is_hkdf_key_compressed = False + + +def test_ephemeral_key_config(): + ECIES_CONFIG.is_ephemeral_key_compressed = True + __check(generate_key()) + ECIES_CONFIG.is_ephemeral_key_compressed = False + + +def test_aes_nonce_config(): + ECIES_CONFIG.symmetric_nonce_length = 12 + __check(generate_key()) + ECIES_CONFIG.symmetric_nonce_length = 16 + + +def test_sym_config(): + ECIES_CONFIG.symmetric_algorithm = "xchacha20" + __check(generate_key()) + ECIES_CONFIG.symmetric_algorithm = "aes-256-gcm" diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..0907b8c --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,80 @@ +import os + +from coincurve import PrivateKey + +from ecies import ECIES_CONFIG +from ecies.utils import ( + decapsulate, + decode_hex, + encapsulate, + sha256, + sym_decrypt, + sym_encrypt, +) +from ecies.utils.symmetric import derive_key + + +def __check_symmetric_random(data: bytes): + key = os.urandom(32) + sym_decrypt(key, sym_encrypt(key, data)) == data + + +def test_hash(): + assert sha256(b"0" * 16).hex()[:8] == "fcdb4b42" + + +def test_hkdf(): + derived = derive_key(b"secret").hex() + assert derived == "2f34e5ff91ec85d53ca9b543683174d0cf550b60d5f52b24c97b386cfcf6cbbf" + + +def test_encapsulate(): + k1 = PrivateKey(secret=bytes([2])) + assert k1.to_int() == 2 + + k2 = PrivateKey(secret=bytes([3])) + assert k2.to_int() == 3 + + assert encapsulate(k1, k2.public_key) == decapsulate(k1.public_key, k2) + assert ( + encapsulate(k1, k2.public_key).hex() + == "6f982d63e8590c9d9b5b4c1959ff80315d772edd8f60287c9361d548d5200f82" + ) + + +def test_aes(): + # test random + __check_symmetric_random("helloworld🌍".encode()) + + # test big + data = b"1" * 1024 * 1024 * 100 # 100 MB + __check_symmetric_random(data) + + # test known + key = decode_hex("0000000000000000000000000000000000000000000000000000000000000000") + nonce = decode_hex("0xf3e1ba810d2c8900b11312b7c725565f") + tag = decode_hex("0Xec3b71e17c11dbe31484da9450edcf6c") + encrypted = decode_hex("02d2ffed93b856f148b9") + data = b"".join([nonce, tag, encrypted]) + assert b"helloworld" == sym_decrypt(key, data) + + +def test_xchacha20(): + ECIES_CONFIG.symmetric_algorithm = "xchacha20" + + # test random + __check_symmetric_random("helloworld🌍".encode()) + + # test big + data = b"1" * 1024 * 1024 * 100 # 100 MB + __check_symmetric_random(data) + + # test known + key = decode_hex("27bd6ec46292a3b421cdaf8a3f0ca759cbc67bcbe7c5855aa0d1e0700fd0e828") + nonce = decode_hex("0xfbd5dd10431af533c403d6f4fa629931e5f31872d2f7e7b6") + tag = decode_hex("0X5b5ccc27324af03b7ca92dd067ad6eb5") + encrypted = decode_hex("aa0664f3c00a09d098bf") + data = b"".join([nonce, tag, encrypted]) + assert b"helloworld" == sym_decrypt(key, data) + + ECIES_CONFIG.symmetric_algorithm = "aes-256-gcm"