From f19100102285a88cadccaefe56c94ef56c19adc7 Mon Sep 17 00:00:00 2001 From: Aleksandr Karpinskii Date: Thu, 12 Jun 2025 15:18:16 +0400 Subject: [PATCH 1/3] Do not use SecureCookie class --- tests.py | 23 ++++++++++++----------- werkzeug_encryptedcookie/__init__.py | 22 ++++++++++++---------- 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/tests.py b/tests.py index 997128a..94f84f9 100644 --- a/tests.py +++ b/tests.py @@ -44,7 +44,7 @@ def test_encrypt_decrypt(self): def test_serialize_unserialize(self): key = b'my little key' for case in [{'a': 'b'}, {'a': 'próba'}, {'próba': '123'}]: - r = self.Cookie(case, key).serialize() + r = self.Cookie(key).serialize(case) assert isinstance(r, bytes) # Check it is ascii r.decode('ascii') @@ -66,22 +66,23 @@ def test_unserialize_binary(self): def test_expires(self): key = b'my little key' - c = self.Cookie({'a': 'próba'}, key) + data = {'a': 'próba'} + c = self.Cookie(key) - r = self.Cookie.unserialize(c.serialize(time() - 1), key) + r = self.Cookie.unserialize(c.serialize(data, time() - 1), key) assert not r # Make sure previous expire not stored in cookie object. # (such bug present in original SecureCookie) - r = self.Cookie.unserialize(c.serialize(), key) + r = self.Cookie.unserialize(c.serialize(data), key) assert r - r = self.Cookie.unserialize(c.serialize(time() + 1), key) + r = self.Cookie.unserialize(c.serialize(data, time() + 1), key) assert r def test_fail_with_another_key(self): - c = self.Cookie({'a': 'próba'}, 'one key') - r = self.Cookie.unserialize(c.serialize(), b'another key') + c = self.Cookie(b'one key') + r = self.Cookie.unserialize(c.serialize({'a': 'próba'}), b'another key') assert not r def test_fail_when_not_json(self): @@ -92,15 +93,15 @@ def test_fail_when_not_json(self): def test_fail_when_corrupted(self): key = b'my little key' - r = self.RawCookie({"a": "próba"}, key).serialize() + r = self.RawCookie(key).serialize({'a': 'próba'}) r = self.RawCookie.unserialize(r[:20] + r[21:], key) assert not r def test_compression_and_decompression(self): key = b'my little key' case = {'a': 'próba'} - no_compress = self.NoCompressCookie(case, key) - compress = self.CompressCookie(case, key) + no_compress = self.NoCompressCookie(key) + compress = self.CompressCookie(key) cases = ( # No-compressed instance unserialized by no-compressed instance (no_compress, no_compress), @@ -112,7 +113,7 @@ def test_compression_and_decompression(self): (compress, compress), ) for cookie1, cookie2 in cases: - result = cookie2.unserialize(cookie1.serialize(), key) + result = cookie2.unserialize(cookie1.serialize(case), key) assert result == case diff --git a/werkzeug_encryptedcookie/__init__.py b/werkzeug_encryptedcookie/__init__.py index 9494ead..3303031 100644 --- a/werkzeug_encryptedcookie/__init__.py +++ b/werkzeug_encryptedcookie/__init__.py @@ -10,14 +10,16 @@ import brotli from Crypto.Cipher import ARC4 -from secure_cookie.cookie import SecureCookie, _date_to_unix +from secure_cookie.cookie import _date_to_unix -class EncryptedCookie(SecureCookie): +class EncryptedCookie: + quote_base64 = True compress_cookie = True compress_cookie_header = b'~!~brtl~!~' - # to avoid deprecation warnings - serialization_method = json + + def __init__(self, secret_key: bytes): + self.secret_key = secret_key @classmethod def _get_cipher(cls, key: bytes) -> ARC4.ARC4Cipher: @@ -37,11 +39,11 @@ def encrypt(cls, data: bytes, secret_key: bytes) -> bytes: def compress(cls, data: bytes) -> bytes: return cls.compress_cookie_header + brotli.compress(data, quality=8) - def serialize(self, expires=None) -> bytes: + def serialize(self, data: dict, expires=None) -> bytes: if self.secret_key is None: raise RuntimeError('no secret key defined') - data = dict(self) + data = data.copy() if expires: data['_expires'] = _date_to_unix(expires) @@ -80,7 +82,7 @@ def decompress(cls, data: bytes) -> bytes: return data @classmethod - def unserialize(cls, string: bytes, secret_key: bytes) -> EncryptedCookie: + def unserialize(cls, string: bytes, secret_key: bytes) -> dict: if cls.quote_base64: try: string = base64.b64decode(string) @@ -93,15 +95,15 @@ def unserialize(cls, string: bytes, secret_key: bytes) -> EncryptedCookie: try: data = cls.loads(payload) except ValueError: - data = None + data = {} if data and '_expires' in data: if time() > data['_expires']: - data = None + data = {} else: del data['_expires'] - return cls(data, secret_key, False) + return data class SecureEncryptedCookie(EncryptedCookie): From 6ce4f189a34e59f9882dfc60102b168e711f53d1 Mon Sep 17 00:00:00 2001 From: Aleksandr Karpinskii Date: Thu, 12 Jun 2025 17:17:58 +0400 Subject: [PATCH 2/3] Improve class API --- tests.py | 78 +++++++++++++++------------- werkzeug_encryptedcookie/__init__.py | 45 +++++++--------- 2 files changed, 60 insertions(+), 63 deletions(-) diff --git a/tests.py b/tests.py index 94f84f9..da2f6ac 100644 --- a/tests.py +++ b/tests.py @@ -25,76 +25,76 @@ def test_dumps_loads(self): assert r == case def test_encrypt_decrypt(self): - key = b'my little key' + cookie = self.Cookie(b'my little key') for case in [b'{"a": "b"}', b'{"a": "pr\xc3\xb3ba"}']: - r1 = self.Cookie.encrypt(case, key) - r2 = self.Cookie.encrypt(case, key) + r1 = cookie.encrypt(case) + r2 = cookie.encrypt(case) assert isinstance(r1, bytes) assert isinstance(r2, bytes) assert r1 != r2 - r1_broken = self.Cookie.decrypt(r1, b'another key') + r1_broken = self.Cookie(b'another key').decrypt(r1) assert r1_broken != case - r1 = self.Cookie.decrypt(r1, key) - r2 = self.Cookie.decrypt(r2, key) + r1 = cookie.decrypt(r1) + r2 = cookie.decrypt(r2) assert r1 == case assert r2 == case def test_serialize_unserialize(self): - key = b'my little key' + cookie = self.Cookie(b'my little key') for case in [{'a': 'b'}, {'a': 'próba'}, {'próba': '123'}]: - r = self.Cookie(key).serialize(case) + r = cookie.serialize(case) assert isinstance(r, bytes) # Check it is ascii r.decode('ascii') - r = self.Cookie.unserialize(r, key) + r = cookie.unserialize(r) assert r == case def test_unserialize_binary(self): """ Test unserialize compatibility with existing binary data. """ - key = b'my little key' + cookie = self.Cookie(b'my little key') for case in [ b'GXCS2JfvmfQJwuxYUITWTmnanyjkIP0IHKbZF2u7oz2qnuIRGuzJbF5JhZrp', b'bvK0dvBIBuPqIrG+o4Zmmu6ln7bLoR+xTz906R8GQAAAaM2rlncYNzsKIsmU', ]: - r = self.Cookie.unserialize(case, key) + r = cookie.unserialize(case) assert {'a': 'próba'} == dict(r) def test_expires(self): - key = b'my little key' + cookie = self.Cookie(b'my little key') data = {'a': 'próba'} - c = self.Cookie(key) + c = cookie - r = self.Cookie.unserialize(c.serialize(data, time() - 1), key) + r = cookie.unserialize(c.serialize(data, time() - 1)) assert not r # Make sure previous expire not stored in cookie object. # (such bug present in original SecureCookie) - r = self.Cookie.unserialize(c.serialize(data), key) + r = cookie.unserialize(c.serialize(data)) assert r - r = self.Cookie.unserialize(c.serialize(data, time() + 1), key) + r = cookie.unserialize(c.serialize(data, time() + 1)) assert r def test_fail_with_another_key(self): - c = self.Cookie(b'one key') - r = self.Cookie.unserialize(c.serialize({'a': 'próba'}), b'another key') + r = self.Cookie(b'one key').serialize({'a': 'próba'}) + r = self.Cookie(b'another key').unserialize(r) assert not r def test_fail_when_not_json(self): - key = b'my little key' - r = self.RawCookie.encrypt(b'{"a", "pr\xc3\xb3ba"}', key) - r = self.RawCookie.unserialize(r, key) + cookie = self.RawCookie(b'my little key') + r = cookie.encrypt(b'{"a", "pr\xc3\xb3ba"}') + r = cookie.unserialize(r) assert not r def test_fail_when_corrupted(self): - key = b'my little key' - r = self.RawCookie(key).serialize({'a': 'próba'}) - r = self.RawCookie.unserialize(r[:20] + r[21:], key) + cookie = self.RawCookie(b'my little key') + r = cookie.serialize({'a': 'próba'}) + r = cookie.unserialize(r[:20] + r[21:]) assert not r def test_compression_and_decompression(self): @@ -113,7 +113,7 @@ def test_compression_and_decompression(self): (compress, compress), ) for cookie1, cookie2 in cases: - result = cookie2.unserialize(cookie1.serialize(case), key) + result = cookie2.unserialize(cookie1.serialize(case)) assert result == case @@ -132,27 +132,33 @@ class CompressCookie(Cookie): # pyright: ignore[reportIncompatibleVariableOverr compress_cookie = True def test_unsigned(self): - key, case = b'my little key', b'{"a": "pr\xc3\xb3ba"}' - r = self.Cookie.encrypt(case, key) - signed = EncryptedCookie.decrypt(r, key) - assert case in signed - - r = EncryptedCookie.encrypt(signed, key) - r = self.Cookie.decrypt(r, key) + secure_cookie = self.Cookie(b'my little key') + unsecure_cookie = EncryptedCookie(b'my little key') + case = b'{"a": "pr\xc3\xb3ba"}' + + # Check that encrypted data is the same as in original cookie + r = secure_cookie.encrypt(case) + signed = unsecure_cookie.decrypt(r) + assert case == signed[:-4] + + # Should be the same as secure_cookie.encrypt(case) + r = unsecure_cookie.encrypt(signed) + r = secure_cookie.decrypt(r) assert r == case - r = EncryptedCookie.encrypt(signed[:-1] + b'!', key) - r = self.Cookie.decrypt(r, key) + # Try to fake signature + r = unsecure_cookie.encrypt(case + b'xxxx') + r = secure_cookie.decrypt(r) assert r == b'' def test_unserialize_binary(self): """ Test unserialize compatibility with existing binary data. """ - key = b'my little key' + cookie = self.Cookie(b'my little key') for case in [ b'vGSOoyvh3KREQNzFhAbhl/oSugKPMJ8QDvp4VWRtSpgUA3670wlkbv1kzA15HQ9oBw==', b'78EM1wnaIkz6FP0EDxHPk6xeGFam2w6cSr6FWosRf6X3H7ILJvhA+gkuq+6AT9iD6g==' ]: - r = self.Cookie.unserialize(case, key) + r = cookie.unserialize(case) assert {'a': 'próba'} == dict(r) diff --git a/werkzeug_encryptedcookie/__init__.py b/werkzeug_encryptedcookie/__init__.py index 3303031..2049d10 100644 --- a/werkzeug_encryptedcookie/__init__.py +++ b/werkzeug_encryptedcookie/__init__.py @@ -21,18 +21,16 @@ class EncryptedCookie: def __init__(self, secret_key: bytes): self.secret_key = secret_key - @classmethod - def _get_cipher(cls, key: bytes) -> ARC4.ARC4Cipher: - return ARC4.new(sha1(key).digest()) + def _get_cipher(self, nonce: bytes) -> ARC4.ARC4Cipher: + return ARC4.new(sha1(self.secret_key + nonce).digest()) @classmethod def dumps(cls, data: dict) -> bytes: return json.dumps(data, ensure_ascii=False).encode() - @classmethod - def encrypt(cls, data: bytes, secret_key: bytes) -> bytes: + def encrypt(self, data: bytes) -> bytes: nonce = secrets.token_bytes(16) - cipher = cls._get_cipher(secret_key + nonce) + cipher = self._get_cipher(nonce) return nonce + cipher.encrypt(data) @classmethod @@ -40,9 +38,6 @@ def compress(cls, data: bytes) -> bytes: return cls.compress_cookie_header + brotli.compress(data, quality=8) def serialize(self, data: dict, expires=None) -> bytes: - if self.secret_key is None: - raise RuntimeError('no secret key defined') - data = data.copy() if expires: data['_expires'] = _date_to_unix(expires) @@ -52,7 +47,7 @@ def serialize(self, data: dict, expires=None) -> bytes: if self.compress_cookie: payload = self.compress(payload) - string = self.encrypt(payload, self.secret_key) + string = self.encrypt(payload) if self.quote_base64: string = base64.b64encode(string) @@ -63,11 +58,10 @@ def serialize(self, data: dict, expires=None) -> bytes: def loads(cls, data: bytes) -> dict: return json.loads(data.decode('utf-8')) - @classmethod - def decrypt(cls, string: bytes, secret_key: bytes) -> bytes: + def decrypt(self, string: bytes) -> bytes: nonce, payload = string[:16], string[16:] - cipher = cls._get_cipher(secret_key + nonce) + cipher = self._get_cipher(nonce) return cipher.decrypt(payload) @classmethod @@ -81,19 +75,18 @@ def decompress(cls, data: bytes) -> bytes: return data - @classmethod - def unserialize(cls, string: bytes, secret_key: bytes) -> dict: - if cls.quote_base64: + def unserialize(self, string: bytes) -> dict: + if self.quote_base64: try: string = base64.b64decode(string) except Exception: pass - payload = cls.decrypt(string, secret_key) - payload = cls.decompress(payload) + payload = self.decrypt(string) + payload = self.decompress(payload) try: - data = cls.loads(payload) + data = self.loads(payload) except ValueError: data = {} @@ -107,17 +100,15 @@ def unserialize(cls, string: bytes, secret_key: bytes) -> dict: class SecureEncryptedCookie(EncryptedCookie): - @classmethod - def encrypt(cls, data: bytes, secret_key: bytes) -> bytes: - crc = zlib.crc32(data, zlib.crc32(secret_key)) + def encrypt(self, data: bytes) -> bytes: + crc = zlib.crc32(data, zlib.crc32(self.secret_key)) data += struct.pack('>I', crc & 0xffffffff) - return super().encrypt(data, secret_key) + return super().encrypt(data) - @classmethod - def decrypt(cls, string: bytes, secret_key: bytes) -> bytes: - data = super().decrypt(string, secret_key) + def decrypt(self, string: bytes) -> bytes: + data = super().decrypt(string) data, crc1 = data[:-4], data[-4:] - crc2 = zlib.crc32(data, zlib.crc32(secret_key)) + crc2 = zlib.crc32(data, zlib.crc32(self.secret_key)) if crc1 != struct.pack('>I', crc2 & 0xffffffff): return b'' return data From ea4f580d8be521af2e88e3833abb7547aba4182b Mon Sep 17 00:00:00 2001 From: Aleksandr Karpinskii Date: Thu, 12 Jun 2025 15:33:55 +0400 Subject: [PATCH 3/3] remove secure_cookie dep --- setup.py | 11 +++++------ tests.py | 11 +++++++++-- werkzeug_encryptedcookie/__init__.py | 17 ++++++++++++++--- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/setup.py b/setup.py index 389e553..db19da0 100644 --- a/setup.py +++ b/setup.py @@ -12,16 +12,15 @@ description='Werkzeug encrypted cookie', packages=['werkzeug_encryptedcookie'], platforms='any', - install_requires=[ - 'pycryptodome>=3.11.0', - 'secure-cookie', - 'brotli>=1.0.1', - 'Werkzeug>=2.0.0,<2.1.0', - ], + install_requires=['pycryptodome>=3.11.0', 'brotli>=1.0.1'], classifiers=[ 'Programming Language :: Python', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 'Topic :: Software Development :: Libraries :: Python Modules' ], diff --git a/tests.py b/tests.py index da2f6ac..2ec5b77 100644 --- a/tests.py +++ b/tests.py @@ -1,3 +1,4 @@ +from datetime import timedelta from time import time from werkzeug_encryptedcookie import EncryptedCookie, SecureEncryptedCookie @@ -75,10 +76,16 @@ def test_expires(self): # Make sure previous expire not stored in cookie object. # (such bug present in original SecureCookie) r = cookie.unserialize(c.serialize(data)) - assert r + assert r == data r = cookie.unserialize(c.serialize(data, time() + 1)) - assert r + assert r == data + + r = cookie.unserialize(c.serialize(data, timedelta(-1))) + assert not r + + r = cookie.unserialize(c.serialize(data, timedelta(1))) + assert r == data def test_fail_with_another_key(self): r = self.Cookie(b'one key').serialize({'a': 'próba'}) diff --git a/werkzeug_encryptedcookie/__init__.py b/werkzeug_encryptedcookie/__init__.py index 2049d10..acda84b 100644 --- a/werkzeug_encryptedcookie/__init__.py +++ b/werkzeug_encryptedcookie/__init__.py @@ -5,12 +5,21 @@ import secrets import struct import zlib +from datetime import timedelta from hashlib import sha1 from time import time import brotli from Crypto.Cipher import ARC4 -from secure_cookie.cookie import _date_to_unix + + +def _date_to_unix(arg: float | int | timedelta): + """ + Converts int or timedelta object into the seconds from epoch in UTC. + """ + if isinstance(arg, timedelta): + arg = time() + arg.total_seconds() + return int(arg) class EncryptedCookie: @@ -37,9 +46,11 @@ def encrypt(self, data: bytes) -> bytes: def compress(cls, data: bytes) -> bytes: return cls.compress_cookie_header + brotli.compress(data, quality=8) - def serialize(self, data: dict, expires=None) -> bytes: + def serialize( + self, data: dict, expires: float | int | timedelta | None = None + ) -> bytes: data = data.copy() - if expires: + if expires is not None: data['_expires'] = _date_to_unix(expires) payload = self.dumps(data)