Skip to content

Commit daaa3a2

Browse files
committed
Added AESCipher that uses the Python cryptography package (in a way that is compatible and interoperable with the AESCipher that uses the pycrytodome package). Should work as a drop-in replacement.
1 parent ccc82c2 commit daaa3a2

8 files changed

+378
-6
lines changed

Makefile

+2-2
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ install-poetry:
1515

1616
.PHONY: install
1717
install:
18-
$(POETRY) install --sync --extras "crypto" --with "docs" -vv $(opts)
18+
$(POETRY) install --sync --extras "crypto cryptography" --with "docs" -vv $(opts)
1919

2020
.PHONY: install-packages
2121
install-packages:
22-
$(POETRY) install --sync --no-root --extras "crypto" --with "docs" -vv $(opts)
22+
$(POETRY) install --sync --no-root --extras "crypto cryptography" --with "docs" -vv $(opts)
2323

2424
.PHONY: update-lockfile
2525
update-lockfile:

eventsourcing/cipher.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616

1717
class AESCipher(Cipher):
1818
"""
19-
Cipher strategy that uses AES cipher in GCM mode.
19+
Cipher strategy that uses AES cipher (in GCM mode)
20+
from the Python pycryptodome package.
2021
"""
2122

2223
CIPHER_KEY = "CIPHER_KEY"
@@ -71,6 +72,7 @@ def encrypt(self, plaintext: bytes) -> bytes:
7172

7273
# Return ciphertext.
7374
return nonce + tag + encrypted
75+
# return nonce + tag + encrypted
7476

7577
def construct_cipher(self, nonce: bytes) -> GcmMode:
7678
cipher = AES.new(

eventsourcing/cryptography.py

+96
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
from __future__ import annotations
2+
3+
import os
4+
from base64 import b64decode, b64encode
5+
from typing import TYPE_CHECKING
6+
7+
from cryptography.exceptions import InvalidTag
8+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
9+
10+
from eventsourcing.persistence import Cipher
11+
12+
if TYPE_CHECKING:
13+
from eventsourcing.utils import Environment
14+
15+
16+
class AESCipher(Cipher):
17+
"""
18+
Cipher strategy that uses AES cipher (in GCM mode)
19+
from the Python cryptography package.
20+
"""
21+
22+
CIPHER_KEY = "CIPHER_KEY"
23+
KEY_SIZES = (16, 24, 32)
24+
25+
@staticmethod
26+
def create_key(num_bytes: int) -> str:
27+
"""
28+
Creates AES cipher key, with length num_bytes.
29+
30+
:param num_bytes: An int value, either 16, 24, or 32.
31+
32+
"""
33+
AESCipher.check_key_size(num_bytes)
34+
key = AESGCM.generate_key(num_bytes * 8)
35+
return b64encode(key).decode("utf8")
36+
37+
@staticmethod
38+
def check_key_size(num_bytes: int) -> None:
39+
if num_bytes not in AESCipher.KEY_SIZES:
40+
msg = f"Invalid key size: {num_bytes} not in {AESCipher.KEY_SIZES}"
41+
raise ValueError(msg)
42+
43+
@staticmethod
44+
def random_bytes(num_bytes: int) -> bytes:
45+
return os.urandom(num_bytes)
46+
47+
def __init__(self, environment: Environment):
48+
"""
49+
Initialises AES cipher with ``cipher_key``.
50+
51+
:param str cipher_key: 16, 24, or 32 bytes encoded as base64
52+
"""
53+
cipher_key = environment.get(self.CIPHER_KEY)
54+
if not cipher_key:
55+
msg = f"'{self.CIPHER_KEY}' not in env"
56+
raise OSError(msg)
57+
key = b64decode(cipher_key.encode("utf8"))
58+
AESCipher.check_key_size(len(key))
59+
self.key = key
60+
61+
def encrypt(self, plaintext: bytes) -> bytes:
62+
"""Return ciphertext for given plaintext."""
63+
64+
# Construct AES-GCM cipher, with 96-bit nonce.
65+
aesgcm = AESGCM(self.key)
66+
nonce = AESCipher.random_bytes(12)
67+
res = aesgcm.encrypt(nonce, plaintext, None)
68+
# Put tag at the front for compatibility with eventsourcing.crypto.AESCipher.
69+
tag = res[-16:]
70+
encrypted = res[:-16]
71+
return nonce + tag + encrypted
72+
73+
def decrypt(self, ciphertext: bytes) -> bytes:
74+
"""Return plaintext for given ciphertext."""
75+
76+
# Split out the nonce, tag, and encrypted data.
77+
nonce = ciphertext[:12]
78+
if len(nonce) != 12:
79+
msg = "Damaged cipher text: invalid nonce length"
80+
raise ValueError(msg)
81+
82+
# Expect tag at the front.
83+
tag = ciphertext[12:28]
84+
if len(tag) != 16:
85+
msg = "Damaged cipher text: invalid tag length"
86+
raise ValueError(msg)
87+
encrypted = ciphertext[28:]
88+
89+
aesgcm = AESGCM(self.key)
90+
try:
91+
plaintext = aesgcm.decrypt(nonce, encrypted + tag, None)
92+
except InvalidTag as e:
93+
msg = "Invalid cipher tag"
94+
raise ValueError(msg) from e
95+
# Decrypt and verify.
96+
return plaintext

poetry.lock

+150-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

+3-1
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,16 @@ keywords=[
4141
]
4242

4343
[tool.poetry.dependencies]
44-
python = ">=3.8,<4.0"
44+
python = ">=3.8,<4.0,!=3.9.0,!=3.9.1"
4545
typing_extensions = "*"
4646
"backports.zoneinfo" = { version = "*", python = "<3.9" }
4747
pycryptodome = { version = "~3.22", optional = true }
48+
cryptography = { version = "~44.0", optional = true }
4849
psycopg = { version = "<=3.2.99999", optional = true, extras=["c,pool"]}
4950

5051
[tool.poetry.extras]
5152
crypto = ["pycryptodome"]
53+
cryptography = ["cryptography"]
5254
postgres = ["psycopg"]
5355

5456
[tool.poetry.group.dev.dependencies]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from unittest import TestCase
2+
3+
import eventsourcing.cipher as pycryptodome
4+
from eventsourcing import cryptography
5+
from eventsourcing.utils import Environment
6+
7+
8+
class TestAesCipherInteroperability(TestCase):
9+
def test(self):
10+
environment = Environment()
11+
key = pycryptodome.AESCipher.create_key(16)
12+
environment["CIPHER_KEY"] = key
13+
14+
aes_pycryptodome = pycryptodome.AESCipher(environment)
15+
aes_cryptography = cryptography.AESCipher(environment)
16+
17+
plain_text = b"some text"
18+
encrypted_text = aes_pycryptodome.encrypt(plain_text)
19+
recovered_text = aes_cryptography.decrypt(encrypted_text)
20+
self.assertEqual(plain_text, recovered_text)
21+
22+
plain_text = b"some text"
23+
encrypted_text = aes_cryptography.encrypt(plain_text)
24+
recovered_text = aes_pycryptodome.decrypt(encrypted_text)
25+
self.assertEqual(plain_text, recovered_text)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
from base64 import b64encode
2+
from unittest.case import TestCase
3+
4+
from eventsourcing.cryptography import AESCipher
5+
from eventsourcing.utils import Environment
6+
7+
8+
class TestAESCipher(TestCase):
9+
def test_createkey(self):
10+
environment = Environment()
11+
12+
# Valid key lengths.
13+
key = AESCipher.create_key(16)
14+
environment["CIPHER_KEY"] = key
15+
AESCipher(environment)
16+
17+
key = AESCipher.create_key(24)
18+
environment["CIPHER_KEY"] = key
19+
AESCipher(environment)
20+
21+
key = AESCipher.create_key(32)
22+
environment["CIPHER_KEY"] = key
23+
AESCipher(environment)
24+
25+
# Non-valid key lengths (on generate key).
26+
with self.assertRaises(ValueError):
27+
AESCipher.create_key(12)
28+
29+
with self.assertRaises(ValueError):
30+
AESCipher.create_key(20)
31+
32+
with self.assertRaises(ValueError):
33+
AESCipher.create_key(28)
34+
35+
with self.assertRaises(ValueError):
36+
AESCipher.create_key(36)
37+
38+
# Non-valid key lengths (on construction).
39+
def create_key(num_bytes):
40+
return b64encode(AESCipher.random_bytes(num_bytes)).decode("utf8")
41+
42+
key = create_key(12)
43+
environment["CIPHER_KEY"] = key
44+
with self.assertRaises(ValueError):
45+
AESCipher(environment)
46+
47+
key = create_key(20)
48+
environment["CIPHER_KEY"] = key
49+
with self.assertRaises(ValueError):
50+
AESCipher(environment)
51+
52+
key = create_key(28)
53+
environment["CIPHER_KEY"] = key
54+
with self.assertRaises(ValueError):
55+
AESCipher(environment)
56+
57+
key = create_key(36)
58+
environment["CIPHER_KEY"] = key
59+
with self.assertRaises(ValueError):
60+
AESCipher(environment)
61+
62+
with self.assertRaises(OSError):
63+
AESCipher(Environment())
64+
65+
def test_encrypt_and_decrypt(self):
66+
environment = Environment()
67+
68+
key = AESCipher.create_key(16)
69+
environment["CIPHER_KEY"] = key
70+
71+
# Check plain text can be encrypted and recovered.
72+
plain_text = b"some text"
73+
cipher = AESCipher(environment)
74+
cipher_text = cipher.encrypt(plain_text)
75+
cipher = AESCipher(environment)
76+
recovered_text = cipher.decrypt(cipher_text)
77+
self.assertEqual(recovered_text, plain_text)
78+
79+
# Check raises on invalid nonce.
80+
with self.assertRaises(ValueError):
81+
cipher.decrypt(cipher_text[:10])
82+
83+
# Check raises on invalid tag.
84+
with self.assertRaises(ValueError):
85+
cipher.decrypt(cipher_text[:20])
86+
87+
# Check raises on invalid data.
88+
with self.assertRaises(ValueError):
89+
cipher.decrypt(cipher_text[:30])
90+
91+
# Check raises on invalid key.
92+
key = AESCipher.create_key(16)
93+
environment["CIPHER_KEY"] = key
94+
cipher = AESCipher(environment)
95+
with self.assertRaises(ValueError):
96+
cipher.decrypt(cipher_text)

tests/persistence_tests/test_aes.py tests/persistence_tests/test_aes_pycryptodome.py

+3
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ def create_key(num_bytes):
5959
with self.assertRaises(ValueError):
6060
AESCipher(environment)
6161

62+
with self.assertRaises(OSError):
63+
AESCipher(Environment())
64+
6265
def test_encrypt_and_decrypt(self):
6366
environment = Environment()
6467

0 commit comments

Comments
 (0)