commit 6e3ea2240578335f383a806adda5d5ffa2699a91 Author: naohide <57.x.mas@gmail.com> Date: Thu Sep 30 23:27:19 2021 +0900 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..21ed6a2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.pyc +dist/ +build/ +*.egg-info/ +.idea +.pypirc \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..74dee72 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,13 @@ +language: python +python: + - "3.9" +env: + - DJANGO_VERSION=2.2 + - DJANGO_VERSION=3.0 + - DJANGO_VERSION=3.1 + - DJANGO_VERSION=3.2 + - DJANGO_VERSION=4.0a1 +install: + - pip install -q Django==$DJANGO_VERSION + - pip install -q -r requirements.txt +script: python manage.py test \ No newline at end of file diff --git a/LICENCE.txt b/LICENCE.txt new file mode 100644 index 0000000..5b922ab --- /dev/null +++ b/LICENCE.txt @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2021 fragment.co.jp + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..163d289 --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +[![Build Status](https://api.travis-ci.com/frgmt/django-fernet-encrypted-fields.png)](https://travis-ci.com/frgmt/django-fernet-encrypted-fields) +[![Pypi Package](https://badge.fury.io/py/django-fernet-encrypted-fields.png)](http://badge.fury.io/py/django-fernet-encrypted-fields) + + +### Django Fernet Encrypted Fields + +This package was created as a successor to [django-encrypted-fields](https://github.com/defrex/django-encrypted-fields). + +#### Getting Started +```shell +$ pip install django-fernet-encrypted-fields +``` +In your `settings.py`, set random SALT_KEY +```python +SALT_KEY = '0123456789abcdefghijklmnopqrstuvwxyz' +``` +Then, in `models.py` +```python +from encrypted_fields.fields import EncryptedTextField + +class MyModel(models.Model): + text_field = EncryptedTextField() +``` +Use your model as normal and your data will be encrypted in the database. + +#### Available Fields + +Currently build in and unit-tested fields. They have the same APIs as their non-encrypted counterparts. + +- `EncryptedCharField` +- `EncryptedTextField` +- `EncryptedDateTimeField` +- `EncryptedIntegerField` +- `EncryptedFloatField` +- `EncryptedEmailField` +- `EncryptedBooleanField` + +### Compatible Django Version + +|Compatible Django Version|Specifically tested| +|---|---| +|`2.2`|:heavy_check_mark:| +|`3.0`|:heavy_check_mark:| +|`3.1`|:heavy_check_mark:| +|`3.2`|:heavy_check_mark:| +|`4.0`|:heavy_check_mark:| \ No newline at end of file diff --git a/encrypted_fields/__init__.py b/encrypted_fields/__init__.py new file mode 100644 index 0000000..b968739 --- /dev/null +++ b/encrypted_fields/__init__.py @@ -0,0 +1 @@ +from .fields import * \ No newline at end of file diff --git a/encrypted_fields/fields.py b/encrypted_fields/fields.py new file mode 100644 index 0000000..fb95f23 --- /dev/null +++ b/encrypted_fields/fields.py @@ -0,0 +1,78 @@ +import base64 +from django.conf import settings +from cryptography.fernet import Fernet +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from django.db import models + + +class EncryptedFieldMixin(object): + salt = bytes(settings.SALT_KEY, 'utf-8') + kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), + length=32, + salt=salt, + iterations=100000, + backend=default_backend()) + + key = base64.urlsafe_b64encode(kdf.derive(settings.SECRET_KEY.encode('utf-8'))) + f = Fernet(key) + + def get_internal_type(self): + """ + To treat everything as text + """ + return 'TextField' + + def get_prep_value(self, value): + if value: + if not isinstance(value, str): + value = str(value) + return self.f.encrypt(bytes(value, 'utf-8')).decode('utf-8') + return None + + def get_db_prep_value(self, value, connection, prepared=False): + if not prepared: + value = self.get_prep_value(value) + return value + + def from_db_value(self, value, expression, connection): + return self.to_python(value) + + def to_python(self, value): + if value is None or not isinstance(value, str): + return value + value = self.f.decrypt(bytes(value, 'utf-8')).decode('utf-8') + return super(EncryptedFieldMixin, self).to_python(value) + + +class EncryptedCharField(EncryptedFieldMixin, models.CharField): + pass + + +class EncryptedTextField(EncryptedFieldMixin, models.TextField): + pass + + +class EncryptedDateTimeField(EncryptedFieldMixin, models.DateTimeField): + pass + + +class EncryptedIntegerField(EncryptedFieldMixin, models.IntegerField): + pass + + +class EncryptedDateField(EncryptedFieldMixin, models.DateField): + pass + + +class EncryptedFloatField(EncryptedFieldMixin, models.FloatField): + pass + + +class EncryptedEmailField(EncryptedFieldMixin, models.EmailField): + pass + + +class EncryptedBooleanField(EncryptedFieldMixin, models.BooleanField): + pass diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..8c4f5d0 --- /dev/null +++ b/manage.py @@ -0,0 +1,9 @@ +import os +import sys + +if __name__ == '__main__': + os.environ['DJANGO_SETTINGS_MODULE'] = 'package_test.settings' + + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) diff --git a/package_test/__init__.py b/package_test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/package_test/models.py b/package_test/models.py new file mode 100644 index 0000000..99da24f --- /dev/null +++ b/package_test/models.py @@ -0,0 +1,12 @@ +from encrypted_fields.fields import * + + +class TestModel(models.Model): + char = EncryptedCharField(max_length=255, null=True, blank=True) + text = EncryptedTextField(null=True, blank=True) + datetime = EncryptedDateTimeField(null=True, blank=True) + integer = EncryptedIntegerField(null=True, blank=True) + date = EncryptedDateField(null=True, blank=True) + floating = EncryptedFloatField(null=True, blank=True) + email = EncryptedEmailField(null=True, blank=True) + boolean = EncryptedBooleanField(default=False, null=True) diff --git a/package_test/settings.py b/package_test/settings.py new file mode 100644 index 0000000..c0d28f4 --- /dev/null +++ b/package_test/settings.py @@ -0,0 +1,18 @@ +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:', + }, +} + +SECRET_KEY = 'abc' +SALT_KEY = 'xyz' + +INSTALLED_APPS = ( + 'encrypted_fields', + 'package_test' +) + +MIDDLEWARE_CLASSES = [] +DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' + diff --git a/package_test/tests.py b/package_test/tests.py new file mode 100644 index 0000000..96caf24 --- /dev/null +++ b/package_test/tests.py @@ -0,0 +1,141 @@ +import re + +from django.db import connection +from django.test import TestCase +from django.utils import timezone + +from .models import TestModel + + +class FieldTest(TestCase): + def get_db_value(self, field, model_id): + cursor = connection.cursor() + cursor.execute( + 'select {0} ' + 'from package_test_testmodel ' + 'where id = {1};'.format(field, model_id) + ) + return cursor.fetchone()[0] + + def test_char_field_encrypted(self): + plaintext = 'Oh hi, test reader!' + + model = TestModel() + model.char = plaintext + model.save() + + ciphertext = self.get_db_value('char', model.id) + + self.assertNotEqual(plaintext, ciphertext) + self.assertTrue('test' not in ciphertext) + + fresh_model = TestModel.objects.get(id=model.id) + self.assertEqual(fresh_model.char, plaintext) + + def test_text_field_encrypted(self): + plaintext = 'Oh hi, test reader!' * 10 + + model = TestModel() + model.text = plaintext + model.save() + + ciphertext = self.get_db_value('text', model.id) + + self.assertNotEqual(plaintext, ciphertext) + self.assertTrue('test' not in ciphertext) + + fresh_model = TestModel.objects.get(id=model.id) + self.assertEqual(fresh_model.text, plaintext) + + def test_datetime_field_encrypted(self): + plaintext = timezone.now() + + model = TestModel() + model.datetime = plaintext + model.save() + + ciphertext = self.get_db_value('datetime', model.id) + + # Django's normal date serialization format + self.assertTrue(re.search('^\d\d\d\d-\d\d-\d\d', ciphertext) is None) + + fresh_model = TestModel.objects.get(id=model.id) + self.assertEqual(fresh_model.datetime, plaintext) + + def test_integer_field_encrypted(self): + plaintext = 42 + + model = TestModel() + model.integer = plaintext + model.save() + + ciphertext = self.get_db_value('integer', model.id) + + self.assertNotEqual(plaintext, ciphertext) + self.assertNotEqual(plaintext, str(ciphertext)) + + fresh_model = TestModel.objects.get(id=model.id) + self.assertEqual(fresh_model.integer, plaintext) + + def test_date_field_encrypted(self): + plaintext = timezone.now().date() + + model = TestModel() + model.date = plaintext + model.save() + + ciphertext = self.get_db_value('date', model.id) + fresh_model = TestModel.objects.get(id=model.id) + + self.assertNotEqual(ciphertext, plaintext.isoformat()) + self.assertEqual(fresh_model.date, plaintext) + + def test_float_field_encrypted(self): + plaintext = 42.44 + + model = TestModel() + model.floating = plaintext + model.save() + + ciphertext = self.get_db_value('floating', model.id) + + self.assertNotEqual(plaintext, ciphertext) + self.assertNotEqual(plaintext, str(ciphertext)) + + fresh_model = TestModel.objects.get(id=model.id) + self.assertEqual(fresh_model.floating, plaintext) + + def test_email_field_encrypted(self): + plaintext = 'test@gmail.com' + + model = TestModel() + model.email = plaintext + model.save() + + ciphertext = self.get_db_value('email', model.id) + + self.assertNotEqual(plaintext, ciphertext) + self.assertTrue('aron' not in ciphertext) + + fresh_model = TestModel.objects.get(id=model.id) + self.assertEqual(fresh_model.email, plaintext) + + def test_boolean_field_encrypted(self): + plaintext = True + + model = TestModel() + model.boolean = plaintext + model.save() + + ciphertext = self.get_db_value('boolean', model.id) + + self.assertNotEqual(plaintext, ciphertext) + self.assertNotEqual(True, ciphertext) + self.assertNotEqual('True', ciphertext) + self.assertNotEqual('true', ciphertext) + self.assertNotEqual('1', ciphertext) + self.assertNotEqual(1, ciphertext) + self.assertTrue(not isinstance(ciphertext, bool)) + + fresh_model = TestModel.objects.get(id=model.id) + self.assertEqual(fresh_model.boolean, plaintext) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7cf0930 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +cffi==1.14.6 +cryptography==35.0.0 +pycparser==2.20 +Django>=2.2 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..51bbe4f --- /dev/null +++ b/setup.py @@ -0,0 +1,19 @@ +from __future__ import print_function +from setuptools import setup + +setup( + name='django-fernet-encrypted-fields', + description=( + 'This is inspired by django-encrypted-fields.' + ), + url='http://github.com/frgmt/django-fernet-encrypted-fields/', + license='MIT', + author='fragment.co.jp', + author_email='info@fragment.co.jp', + packages=['encrypted_fields'], + version='0.0.1', + install_requires=[ + 'Django>=2.2', + 'cryptography>=35.0.0', + ], +) \ No newline at end of file