passerelle/passerelle/apps/cryptor/models.py

237 lines
8.3 KiB
Python

# passerelle - uniform access to multiple data sources and services
# Copyright (C) 2020 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import base64
import binascii
import json
import os
from uuid import uuid4
from Cryptodome.PublicKey import RSA
from Cryptodome.Random import get_random_bytes
from Cryptodome.Cipher import AES, PKCS1_OAEP
from Cryptodome.Hash import SHA512
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.db import models
from django.http import HttpResponse
from django.utils.six.moves.urllib_parse import urljoin
from django.utils.translation import ugettext_lazy as _
from passerelle.base.models import BaseResource
from passerelle.utils.api import endpoint
from passerelle.utils.files import atomic_write
from passerelle.utils.jsonresponse import APIError
FILE_SCHEMA = {
"$schema": "http://json-schema.org/draft-04/schema#",
"title": "File to encrypt",
"description": "",
"type": "object",
"required": ["file"],
"properties": {
"file": {
"type": "object",
"required": ["filename", "content_type", "content"],
"properties": {
"filename": {"type": "string"},
"content_type": {"type": "string"},
"content": {"type": "string"},
},
}
},
}
# encrypt and decrypt are borrowed from
# https://www.pycryptodome.org/en/latest/src/examples.html#encrypt-data-with-rsa
def write_encrypt(out_file, data, key_pem):
public_key = RSA.import_key(key_pem)
session_key = get_random_bytes(16)
# Encrypt the session key with the public RSA key
cipher_rsa = PKCS1_OAEP.new(public_key, hashAlgo=SHA512)
enc_session_key = cipher_rsa.encrypt(session_key)
# Encrypt the data with the AES session key
cipher_aes = AES.new(session_key, AES.MODE_EAX)
ciphertext, tag = cipher_aes.encrypt_and_digest(data)
# Store in out_file
out_file.write(enc_session_key)
out_file.write(cipher_aes.nonce)
out_file.write(tag)
out_file.write(ciphertext)
def read_decrypt(in_file, key_pem):
private_key = RSA.import_key(key_pem)
# Get crypt elements from in_file
enc_session_key = in_file.read(private_key.size_in_bytes())
nonce = in_file.read(16)
tag = in_file.read(16)
ciphertext = in_file.read()
# Decrypt the session key with the private RSA key
cipher_rsa = PKCS1_OAEP.new(private_key, hashAlgo=SHA512)
session_key = cipher_rsa.decrypt(enc_session_key)
# Decrypt the data with the AES session key
cipher_aes = AES.new(session_key, AES.MODE_EAX, nonce)
decrypted = cipher_aes.decrypt_and_verify(ciphertext, tag)
return decrypted
def makedir(dir_name):
if not os.path.exists(dir_name):
if default_storage.directory_permissions_mode:
d_umask = os.umask(0)
try:
os.makedirs(dir_name, mode=default_storage.directory_permissions_mode)
except OSError:
pass
finally:
os.umask(d_umask)
else:
os.makedirs(dir_name)
def validate_rsa_key(key):
try:
RSA.import_key(key)
except ValueError as ex:
raise ValidationError(_('invalid RSA key (%s)') % ex)
class Cryptor(BaseResource):
public_key = models.TextField(
blank=True, verbose_name=_('Encryption RSA public key (PEM format)'), validators=[validate_rsa_key]
)
private_key = models.TextField(
blank=True, verbose_name=_('Decryption RSA private key (PEM format)'), validators=[validate_rsa_key]
)
redirect_url_base = models.URLField(
max_length=256,
blank=True,
verbose_name=_('Base URL of decryption system'),
help_text=_('Base URL for redirect, empty for local'),
)
category = _('Misc')
class Meta:
verbose_name = _('Encryption / Decryption')
def get_redirect_url_base_display(self):
if self.redirect_url_base:
return _('defined') # don't show it, can be sensitive
return _('this file-decrypt endpoint')
def get_filename(self, uuid, create=False):
dirname = os.path.join(
default_storage.path(self.get_connector_slug()), self.slug, uuid[0:2], uuid[2:4]
)
if create:
makedir(dirname)
filename = os.path.join(dirname, uuid)
return filename
@endpoint(
name='file-encrypt',
perm='can_encrypt',
description=_('Encrypt a file'),
post={
'description': _('File to encrypt'),
'request_body': {'schema': {'application/json': FILE_SCHEMA}},
},
)
def file_encrypt(self, request, post_data):
if not self.public_key:
raise APIError('missing public key')
try:
data = base64.b64decode(post_data['file']['content'])
except (TypeError, binascii.Error):
raise APIError('invalid base64 string', http_status=400)
filename = post_data['file']['filename']
content_type = post_data['file']['content_type']
cfile = CryptedFile(resource=self, filename=filename, content_type=content_type)
cfile.save()
uuid = str(cfile.uuid) # get string representation of UUID object
if self.redirect_url_base:
redirect_url_base = self.redirect_url_base
else:
redirect_url_base = request.build_absolute_uri('%sfile-decrypt/' % (self.get_absolute_url(),))
redirect_url = urljoin(redirect_url_base, uuid)
content_filename = self.get_filename(uuid, create=True)
metadata_filename = '%s.json' % content_filename
metadata = {
'filename': cfile.filename,
'content_type': cfile.content_type,
'creation_timestamp': cfile.creation_timestamp.isoformat(),
'redirect_url': redirect_url,
}
tmp_dir = os.path.join(default_storage.path(self.get_connector_slug()), self.slug, 'tmp')
with atomic_write(content_filename, dir=tmp_dir) as fd:
write_encrypt(fd, data, self.public_key)
with atomic_write(metadata_filename, dir=tmp_dir, mode='w') as fd:
json.dump(metadata, fd, indent=2)
return {'data': metadata}
@endpoint(
name='file-decrypt',
perm='can_decrypt',
description=_('Decrypt a file'),
pattern=r'(?P<uuid>[\w-]+)$',
example_pattern='{uuid}/',
parameters={
'uuid': {
'description': _('File identifier'),
'example_value': '12345678-abcd-4321-abcd-123456789012',
},
},
)
def file_decrypt(self, request, uuid):
if not self.private_key:
raise APIError('missing private key')
content_filename = self.get_filename(uuid, create=False)
metadata_filename = '%s.json' % content_filename
if not os.path.exists(metadata_filename):
raise APIError('unknown uuid', http_status=404)
content = read_decrypt(open(content_filename, 'rb'), self.private_key)
metadata = json.load(open(metadata_filename, 'r'))
filename = metadata.get('filename')
content_type = metadata.get('content_type')
response = HttpResponse(content_type=content_type)
response['Content-Disposition'] = 'inline; filename="%s"' % filename
response.write(content)
return response
class CryptedFile(models.Model):
resource = models.ForeignKey(Cryptor, on_delete=models.PROTECT)
uuid = models.UUIDField(primary_key=True, default=uuid4, editable=False)
filename = models.CharField(max_length=512, blank=False)
content_type = models.CharField(max_length=128)
creation_timestamp = models.DateTimeField(auto_now_add=True)