mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-18 01:32:17 -06:00
Initial push to public repo
This commit is contained in:
0
netbox/secrets/__init__.py
Normal file
0
netbox/secrets/__init__.py
Normal file
71
netbox/secrets/admin.py
Normal file
71
netbox/secrets/admin.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from django.contrib import admin, messages
|
||||
from django.shortcuts import redirect, render
|
||||
|
||||
from .forms import ActivateUserKeyForm
|
||||
from .models import UserKey, SecretRole, Secret
|
||||
|
||||
|
||||
@admin.register(UserKey)
|
||||
class UserKeyAdmin(admin.ModelAdmin):
|
||||
actions = ['activate_selected']
|
||||
list_display = ['user', 'is_filled', 'is_active', 'created']
|
||||
fields = ['user', 'public_key', 'is_active', 'last_modified']
|
||||
readonly_fields = ['is_active', 'last_modified']
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
# Don't allow a user to modify an existing public key directly.
|
||||
if obj and obj.public_key:
|
||||
return ['public_key'] + self.readonly_fields
|
||||
return self.readonly_fields
|
||||
|
||||
def get_actions(self, request):
|
||||
# Bulk deletion is disabled at the manager level, so remove the action from the admin site for this model.
|
||||
actions = super(UserKeyAdmin, self).get_actions(request)
|
||||
if 'delete_selected' in actions:
|
||||
del actions['delete_selected']
|
||||
if not request.user.has_perm('secrets.activate_userkey'):
|
||||
del actions['activate_selected']
|
||||
return actions
|
||||
|
||||
def activate_selected(modeladmin, request, queryset):
|
||||
"""
|
||||
Enable bulk activation of UserKeys
|
||||
"""
|
||||
try:
|
||||
my_userkey = UserKey.objects.get(user=request.user)
|
||||
except UserKey.DoesNotExist:
|
||||
messages.error(request, "You do not have an active User Key.")
|
||||
return redirect('/admin/secrets/userkey/')
|
||||
|
||||
if 'activate' in request.POST:
|
||||
form = ActivateUserKeyForm(request.POST)
|
||||
if form.is_valid():
|
||||
try:
|
||||
master_key = my_userkey.get_master_key(form.cleaned_data['secret_key'])
|
||||
for uk in form.cleaned_data['_selected_action']:
|
||||
uk.activate(master_key)
|
||||
return redirect('/admin/secrets/userkey/')
|
||||
except ValueError:
|
||||
messages.error(request, "Invalid private key provided. Unable to retrieve master key.")
|
||||
else:
|
||||
form = ActivateUserKeyForm(initial={'_selected_action': request.POST.getlist(admin.ACTION_CHECKBOX_NAME)})
|
||||
|
||||
return render(request, 'activate_keys.html', {
|
||||
'form': form,
|
||||
})
|
||||
activate_selected.short_description = "Activate selected user keys"
|
||||
|
||||
|
||||
@admin.register(SecretRole)
|
||||
class SecretRoleAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'slug']
|
||||
prepopulated_fields = {
|
||||
'slug': ['name'],
|
||||
}
|
||||
|
||||
|
||||
@admin.register(Secret)
|
||||
class SecretAdmin(admin.ModelAdmin):
|
||||
list_display = ['parent', 'role', 'name', 'created', 'last_modified']
|
||||
fields = ['parent', 'role', 'name', 'hash', 'created', 'last_modified']
|
||||
readonly_fields = ['parent', 'hash', 'created', 'last_modified']
|
||||
0
netbox/secrets/api/__init__.py
Normal file
0
netbox/secrets/api/__init__.py
Normal file
39
netbox/secrets/api/serializers.py
Normal file
39
netbox/secrets/api/serializers.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from secrets.models import Secret, SecretRole
|
||||
|
||||
|
||||
#
|
||||
# SecretRoles
|
||||
#
|
||||
|
||||
class SecretRoleSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = SecretRole
|
||||
fields = ['id', 'name', 'slug']
|
||||
|
||||
|
||||
class SecretRoleNestedSerializer(SecretRoleSerializer):
|
||||
|
||||
class Meta(SecretRoleSerializer.Meta):
|
||||
pass
|
||||
|
||||
|
||||
#
|
||||
# Secrets
|
||||
#
|
||||
|
||||
# TODO: Serialize parent info
|
||||
class SecretSerializer(serializers.ModelSerializer):
|
||||
role = SecretRoleNestedSerializer()
|
||||
|
||||
class Meta:
|
||||
model = Secret
|
||||
fields = ['id', 'role', 'name', 'hash', 'created', 'last_modified']
|
||||
|
||||
|
||||
class SecretNestedSerializer(SecretSerializer):
|
||||
|
||||
class Meta(SecretSerializer.Meta):
|
||||
fields = ['id', 'name']
|
||||
20
netbox/secrets/api/urls.py
Normal file
20
netbox/secrets/api/urls.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from django.conf.urls import url
|
||||
|
||||
from .views import *
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
|
||||
# Secrets
|
||||
url(r'^secrets/$', SecretListView.as_view(), name='secret_list'),
|
||||
url(r'^secrets/(?P<pk>\d+)/$', SecretDetailView.as_view(), name='secret_detail'),
|
||||
url(r'^secrets/(?P<pk>\d+)/decrypt/$', SecretDecryptView.as_view(), name='secret_decrypt'),
|
||||
|
||||
# Secret roles
|
||||
url(r'^secret-roles/$', SecretRoleListView.as_view(), name='secretrole_list'),
|
||||
url(r'^secret-roles/(?P<pk>\d+)/$', SecretRoleDetailView.as_view(), name='secretrole_detail'),
|
||||
|
||||
# Miscellaneous
|
||||
url(r'^generate-keys/$', RSAKeyGeneratorView.as_view(), name='generate_keys'),
|
||||
|
||||
]
|
||||
104
netbox/secrets/api/views.py
Normal file
104
netbox/secrets/api/views.py
Normal file
@@ -0,0 +1,104 @@
|
||||
from Crypto.PublicKey import RSA
|
||||
|
||||
from django.http import HttpResponseForbidden
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from rest_framework import generics
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from secrets.models import Secret, SecretRole, UserKey
|
||||
from .serializers import SecretRoleSerializer, SecretSerializer
|
||||
|
||||
|
||||
class SecretRoleListView(generics.ListAPIView):
|
||||
"""
|
||||
List all secret roles
|
||||
"""
|
||||
queryset = SecretRole.objects.all()
|
||||
serializer_class = SecretRoleSerializer
|
||||
|
||||
|
||||
class SecretRoleDetailView(generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single secret role
|
||||
"""
|
||||
queryset = SecretRole.objects.all()
|
||||
serializer_class = SecretRoleSerializer
|
||||
|
||||
|
||||
class SecretListView(generics.ListAPIView):
|
||||
"""
|
||||
List secrets (filterable)
|
||||
"""
|
||||
queryset = Secret.objects.select_related('role')
|
||||
serializer_class = SecretSerializer
|
||||
#filter_class = SecretFilter
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
|
||||
class SecretDetailView(generics.RetrieveAPIView):
|
||||
"""
|
||||
Retrieve a single Secret
|
||||
"""
|
||||
queryset = Secret.objects.select_related('role')
|
||||
serializer_class = SecretSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
|
||||
class SecretDecryptView(APIView):
|
||||
"""
|
||||
Retrieve the plaintext from a stored Secret. The request must include a valid private key.
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request, pk):
|
||||
|
||||
secret = get_object_or_404(Secret, pk=pk)
|
||||
private_key = request.POST.get('private_key')
|
||||
if not private_key:
|
||||
raise ValidationError("Private key is missing from request.")
|
||||
|
||||
# Retrieve the Secret's plaintext with the user's private key
|
||||
try:
|
||||
uk = UserKey.objects.get(user=request.user)
|
||||
except UserKey.DoesNotExist:
|
||||
return HttpResponseForbidden(reason="No UserKey found.")
|
||||
if not uk.is_active():
|
||||
return HttpResponseForbidden(reason="UserKey is inactive.")
|
||||
|
||||
# Attempt to decrypt the Secret.
|
||||
master_key = uk.get_master_key(private_key)
|
||||
if master_key is None:
|
||||
return HttpResponseForbidden(reason="Invalid secret key.")
|
||||
secret.decrypt(master_key)
|
||||
|
||||
return Response({
|
||||
'plaintext': secret.plaintext,
|
||||
})
|
||||
|
||||
|
||||
class RSAKeyGeneratorView(APIView):
|
||||
"""
|
||||
Generate a new RSA key pair for a user. Authenticated because it's a ripe avenue for DoS.
|
||||
"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
|
||||
# Determine what size key to generate
|
||||
key_size = request.GET.get('key_size', 2048)
|
||||
if key_size not in range(2048, 4097, 256):
|
||||
key_size = 2048
|
||||
|
||||
# Export RSA private and public keys in PEM format
|
||||
key = RSA.generate(key_size)
|
||||
private_key = key.exportKey('PEM')
|
||||
public_key = key.publickey().exportKey('PEM')
|
||||
|
||||
return Response({
|
||||
'private_key': private_key,
|
||||
'public_key': public_key,
|
||||
})
|
||||
7
netbox/secrets/apps.py
Normal file
7
netbox/secrets/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class SecretsConfig(AppConfig):
|
||||
name = 'secrets'
|
||||
24
netbox/secrets/decorators.py
Normal file
24
netbox/secrets/decorators.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from django.contrib import messages
|
||||
from django.shortcuts import redirect
|
||||
|
||||
from .models import UserKey
|
||||
|
||||
|
||||
def userkey_required():
|
||||
"""
|
||||
Decorator for views which require that the user has an active UserKey (typically for encryption/decryption of
|
||||
Secrets).
|
||||
"""
|
||||
def _decorator(view):
|
||||
def wrapped_view(request, *args, **kwargs):
|
||||
try:
|
||||
uk = UserKey.objects.get(user=request.user)
|
||||
except UserKey.DoesNotExist:
|
||||
messages.warning(request, "This operation requires an active user key, but you don't have one.")
|
||||
return redirect('users:userkey')
|
||||
if not uk.is_active():
|
||||
messages.warning(request, "This operation is not available. Your user key has not been activated.")
|
||||
return redirect('users:userkey')
|
||||
return view(request, *args, **kwargs)
|
||||
return wrapped_view
|
||||
return _decorator
|
||||
21
netbox/secrets/filters.py
Normal file
21
netbox/secrets/filters.py
Normal file
@@ -0,0 +1,21 @@
|
||||
import django_filters
|
||||
|
||||
from .models import Secret, SecretRole
|
||||
|
||||
|
||||
class SecretFilter(django_filters.FilterSet):
|
||||
role_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='role',
|
||||
queryset=SecretRole.objects.all(),
|
||||
label='Role (ID)',
|
||||
)
|
||||
role = django_filters.ModelMultipleChoiceFilter(
|
||||
name='role',
|
||||
queryset=SecretRole.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Role (slug)',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Secret
|
||||
fields = ['name', 'role_id', 'role']
|
||||
146
netbox/secrets/forms.py
Normal file
146
netbox/secrets/forms.py
Normal file
@@ -0,0 +1,146 @@
|
||||
from Crypto.Cipher import PKCS1_OAEP
|
||||
from Crypto.PublicKey import RSA
|
||||
|
||||
from django import forms
|
||||
from django.apps import apps
|
||||
from django.db.models import Count
|
||||
|
||||
from utilities.forms import BootstrapMixin, ConfirmationForm, CSVDataField
|
||||
from .models import Secret, SecretRole, UserKey
|
||||
|
||||
|
||||
def validate_rsa_key(key, is_secret=True):
|
||||
"""
|
||||
Validate the format and type of an RSA key.
|
||||
"""
|
||||
try:
|
||||
key = RSA.importKey(key)
|
||||
except ValueError:
|
||||
raise forms.ValidationError("Invalid RSA key. Please ensure that your key is in PEM (base64) format.")
|
||||
except Exception as e:
|
||||
raise forms.ValidationError("Invalid key detected: {}".format(e))
|
||||
if is_secret and not key.has_private():
|
||||
raise forms.ValidationError("This looks like a public key. Please provide your private RSA key.")
|
||||
elif not is_secret and key.has_private():
|
||||
raise forms.ValidationError("This looks like a private key. Please provide your public RSA key.")
|
||||
try:
|
||||
PKCS1_OAEP.new(key)
|
||||
except:
|
||||
raise forms.ValidationError("Error validating RSA key. Please ensure that your key supports PKCS#1 OAEP.")
|
||||
|
||||
|
||||
#
|
||||
# Secrets
|
||||
#
|
||||
|
||||
class SecretForm(forms.ModelForm, BootstrapMixin):
|
||||
private_key = forms.CharField(widget=forms.HiddenInput())
|
||||
plaintext = forms.CharField(max_length=65535, required=False, label='Plaintext')
|
||||
plaintext2 = forms.CharField(max_length=65535, required=False, label='Plaintext (verify)')
|
||||
|
||||
class Meta:
|
||||
model = Secret
|
||||
fields = ['role', 'name', 'plaintext', 'plaintext2']
|
||||
|
||||
def clean(self):
|
||||
validate_rsa_key(self.cleaned_data['private_key'])
|
||||
|
||||
def clean_plaintext2(self):
|
||||
plaintext = self.cleaned_data['plaintext']
|
||||
plaintext2 = self.cleaned_data['plaintext2']
|
||||
if plaintext != plaintext2:
|
||||
raise forms.ValidationError("The two given plaintext values do not match. Please check your input.")
|
||||
|
||||
|
||||
class SecretFromCSVForm(forms.ModelForm):
|
||||
parent_name = forms.CharField()
|
||||
role = forms.ModelChoiceField(queryset=SecretRole.objects.all(), to_field_name='name',
|
||||
error_messages={'invalid_choice': 'Invalid secret role.'})
|
||||
plaintext = forms.CharField()
|
||||
|
||||
class Meta:
|
||||
model = Secret
|
||||
fields = ['parent_name', 'role', 'name', 'plaintext']
|
||||
|
||||
|
||||
class SecretImportForm(forms.Form, BootstrapMixin):
|
||||
private_key = forms.CharField(widget=forms.HiddenInput())
|
||||
parent_type = forms.ChoiceField(label='Parent Type', choices=(
|
||||
('dcim.Device', 'Device'),
|
||||
))
|
||||
csv = CSVDataField(csv_form=SecretFromCSVForm)
|
||||
|
||||
def clean(self):
|
||||
parent_type = self.cleaned_data.get('parent_type')
|
||||
records = self.cleaned_data.get('csv')
|
||||
if not records or not parent_type:
|
||||
return
|
||||
|
||||
secrets = []
|
||||
parent_cls = apps.get_model(parent_type)
|
||||
|
||||
for i, record in enumerate(records, start=1):
|
||||
secret_form = SecretFromCSVForm(data=record)
|
||||
if secret_form.is_valid():
|
||||
s = secret_form.save(commit=False)
|
||||
# Set parent
|
||||
try:
|
||||
s.parent = parent_cls.objects.get(name=secret_form.cleaned_data['parent_name'])
|
||||
except parent_cls.DoesNotExist:
|
||||
self.add_error('csv', "Invalid parent object ({})".format(secret_form.cleaned_data['parent_name']))
|
||||
# Set plaintext
|
||||
s.plaintext = str(secret_form.cleaned_data['plaintext'])
|
||||
secrets.append(s)
|
||||
else:
|
||||
for field, errors in secret_form.errors.items():
|
||||
for e in errors:
|
||||
self.add_error('csv', "Record {} {}: {}".format(i, field, e))
|
||||
|
||||
self.cleaned_data['csv'] = secrets
|
||||
|
||||
|
||||
class SecretBulkEditForm(forms.Form, BootstrapMixin):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Secret.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
role = forms.ModelChoiceField(queryset=SecretRole.objects.all())
|
||||
name = forms.CharField(max_length=100, required=False)
|
||||
|
||||
|
||||
class SecretBulkDeleteForm(ConfirmationForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Secret.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
|
||||
|
||||
def secret_role_choices():
|
||||
role_choices = SecretRole.objects.annotate(secret_count=Count('secrets'))
|
||||
return [(r.slug, '{} ({})'.format(r.name, r.secret_count)) for r in role_choices]
|
||||
|
||||
|
||||
class SecretFilterForm(forms.Form, BootstrapMixin):
|
||||
role = forms.MultipleChoiceField(required=False, choices=secret_role_choices)
|
||||
|
||||
|
||||
#
|
||||
# UserKeys
|
||||
#
|
||||
|
||||
class UserKeyForm(forms.ModelForm, BootstrapMixin):
|
||||
|
||||
class Meta:
|
||||
model = UserKey
|
||||
fields = ['public_key']
|
||||
help_texts = {
|
||||
'public_key': "Enter your public RSA key. Keep the private one with you; you'll need it for decryption.",
|
||||
}
|
||||
|
||||
def clean_public_key(self):
|
||||
key = self.cleaned_data['public_key']
|
||||
|
||||
# Validate the RSA key format.
|
||||
validate_rsa_key(key, is_secret=False)
|
||||
|
||||
return key
|
||||
|
||||
|
||||
class ActivateUserKeyForm(forms.Form):
|
||||
_selected_action = forms.ModelMultipleChoiceField(queryset=UserKey.objects.all(), label='User Keys')
|
||||
secret_key = forms.CharField(label='Your private key', widget=forms.Textarea(attrs={'class': 'vLargeTextField'}))
|
||||
|
||||
68
netbox/secrets/migrations/0001_initial.py
Normal file
68
netbox/secrets/migrations/0001_initial.py
Normal file
@@ -0,0 +1,68 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9.1 on 2016-02-27 02:35
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Secret',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('object_id', models.PositiveIntegerField()),
|
||||
('name', models.CharField(blank=True, max_length=100)),
|
||||
('ciphertext', models.BinaryField(max_length=65568)),
|
||||
('hash', models.CharField(editable=False, max_length=128)),
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name=b'Created')),
|
||||
('last_modified', models.DateTimeField(auto_now=True, verbose_name=b'Last modified')),
|
||||
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['role', 'name'],
|
||||
'permissions': (('view_secret', 'Can view secrets'),),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SecretRole',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=50, unique=True)),
|
||||
('slug', models.SlugField(unique=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='UserKey',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('public_key', models.TextField(verbose_name=b'RSA public key')),
|
||||
('master_key_cipher', models.BinaryField(blank=True, max_length=512, null=True)),
|
||||
('created', models.DateTimeField(auto_now_add=True, verbose_name=b'Time created')),
|
||||
('last_modified', models.DateTimeField(auto_now=True, verbose_name=b'Last modified')),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='user_key', to=settings.AUTH_USER_MODEL, verbose_name=b'User')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['user__username'],
|
||||
'permissions': (('activate_userkey', 'Can activate user keys for decryption'),),
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='secret',
|
||||
name='role',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='secrets', to='secrets.SecretRole'),
|
||||
),
|
||||
]
|
||||
0
netbox/secrets/migrations/__init__.py
Normal file
0
netbox/secrets/migrations/__init__.py
Normal file
282
netbox/secrets/models.py
Normal file
282
netbox/secrets/models.py
Normal file
@@ -0,0 +1,282 @@
|
||||
import os
|
||||
from Crypto.Cipher import AES, PKCS1_OAEP
|
||||
from Crypto.PublicKey import RSA
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import make_password, check_password
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.db import models
|
||||
from django.utils.encoding import force_bytes
|
||||
|
||||
|
||||
def generate_master_key():
|
||||
"""
|
||||
Generate a new 256-bit (32 bytes) AES key to be used for symmetric encryption of secrets.
|
||||
"""
|
||||
return os.urandom(32)
|
||||
|
||||
|
||||
def encrypt_master_key(master_key, public_key):
|
||||
"""
|
||||
Encrypt a secret key with the provided public RSA key.
|
||||
"""
|
||||
key = RSA.importKey(public_key)
|
||||
cipher = PKCS1_OAEP.new(key)
|
||||
return cipher.encrypt(master_key)
|
||||
|
||||
|
||||
def decrypt_master_key(master_key_cipher, private_key):
|
||||
"""
|
||||
Decrypt a secret key with the provided private RSA key.
|
||||
"""
|
||||
key = RSA.importKey(private_key)
|
||||
cipher = PKCS1_OAEP.new(key)
|
||||
return cipher.decrypt(master_key_cipher)
|
||||
|
||||
|
||||
class UserKeyQuerySet(models.QuerySet):
|
||||
|
||||
def active(self):
|
||||
return self.filter(master_key_cipher__isnull=False)
|
||||
|
||||
def delete(self):
|
||||
# Disable bulk deletion to avoid accidentally wiping out all copies of the master key.
|
||||
raise Exception("Bulk deletion has been disabled.")
|
||||
|
||||
|
||||
class UserKey(models.Model):
|
||||
"""
|
||||
A user's personal public RSA key.
|
||||
"""
|
||||
user = models.OneToOneField(User, related_name='user_key', verbose_name='User')
|
||||
public_key = models.TextField(verbose_name='RSA public key')
|
||||
master_key_cipher = models.BinaryField(max_length=512, blank=True, null=True, editable=False)
|
||||
created = models.DateTimeField(auto_now_add=True, verbose_name='Time created')
|
||||
last_modified = models.DateTimeField(auto_now=True, verbose_name='Last modified')
|
||||
|
||||
objects = UserKeyQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['user__username']
|
||||
permissions = (
|
||||
('activate_userkey', "Can activate user keys for decryption"),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(UserKey, self).__init__(*args, **kwargs)
|
||||
|
||||
# Store the initial public_key and master_key_cipher to check for changes on save().
|
||||
self.__initial_public_key = self.public_key
|
||||
self.__initial_master_key_cipher = self.master_key_cipher
|
||||
|
||||
def __unicode__(self):
|
||||
return self.user.username
|
||||
|
||||
def clean(self, *args, **kwargs):
|
||||
|
||||
# Validate the public key format and length.
|
||||
if self.public_key:
|
||||
try:
|
||||
pubkey = RSA.importKey(self.public_key)
|
||||
except ValueError:
|
||||
raise ValidationError("Invalid RSA key format.")
|
||||
except:
|
||||
raise ValidationError("Something went wrong while trying to save your key. Please ensure that you're "
|
||||
"uploading a valid RSA public key in PEM format (no SSH/PGP).")
|
||||
# key.size() returns 1 less than the key modulus
|
||||
pubkey_length = pubkey.size() + 1
|
||||
if pubkey_length < settings.SECRETS_MIN_PUBKEY_SIZE:
|
||||
raise ValidationError("Insufficient key length. Keys must be at least {} bits long."
|
||||
.format(settings.SECRETS_MIN_PUBKEY_SIZE))
|
||||
# We can't use keys bigger than our master_key_cipher field can hold
|
||||
if pubkey_length > 4096:
|
||||
raise ValidationError("Public key size ({}) is too large. Maximum key size is 4096 bits."
|
||||
.format(pubkey_length))
|
||||
|
||||
super(UserKey, self).clean()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# Check whether public_key has been modified. If so, nullify the initial master_key_cipher.
|
||||
if self.__initial_master_key_cipher and self.public_key != self.__initial_public_key:
|
||||
self.master_key_cipher = None
|
||||
|
||||
# If no other active UserKeys exist, generate a new master key and use it to activate this UserKey.
|
||||
if self.is_filled() and not self.is_active() and not UserKey.objects.active().count():
|
||||
master_key = generate_master_key()
|
||||
self.master_key_cipher = encrypt_master_key(master_key, self.public_key)
|
||||
|
||||
super(UserKey, self).save(*args, **kwargs)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
|
||||
# If Secrets exist and this is the last active UserKey, prevent its deletion. Deleting the last UserKey will
|
||||
# result in the master key being destroyed and rendering all Secrets inaccessible.
|
||||
if Secret.objects.count() and [uk.pk for uk in UserKey.objects.active()] == [self.pk]:
|
||||
raise Exception("Cannot delete the last active UserKey when Secrets exist! This would render all secrets "
|
||||
"inaccessible.")
|
||||
|
||||
super(UserKey, self).delete(*args, **kwargs)
|
||||
|
||||
def is_filled(self):
|
||||
"""
|
||||
Returns True if the UserKey has been filled with a public RSA key.
|
||||
"""
|
||||
return bool(self.public_key)
|
||||
is_filled.boolean = True
|
||||
|
||||
def is_active(self):
|
||||
"""
|
||||
Returns True if the UserKey has been populated with an encrypted copy of the master key.
|
||||
"""
|
||||
return self.master_key_cipher is not None
|
||||
is_active.boolean = True
|
||||
|
||||
def get_master_key(self, private_key):
|
||||
"""
|
||||
Given the User's private key, return the encrypted master key.
|
||||
"""
|
||||
if not self.is_active:
|
||||
raise ValueError("Unable to retrieve master key: UserKey is inactive.")
|
||||
try:
|
||||
return decrypt_master_key(force_bytes(self.master_key_cipher), private_key)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def activate(self, master_key):
|
||||
"""
|
||||
Activate the UserKey by saving an encrypted copy of the master key to the database.
|
||||
"""
|
||||
if not self.public_key:
|
||||
raise Exception("Cannot activate UserKey: Its public key must be filled first.")
|
||||
self.master_key_cipher = encrypt_master_key(master_key, self.public_key)
|
||||
self.save()
|
||||
|
||||
|
||||
class SecretRole(models.Model):
|
||||
"""
|
||||
A functional classification of secret type. For example: login credentials, SNMP communities, etc.
|
||||
"""
|
||||
name = models.CharField(max_length=50, unique=True)
|
||||
slug = models.SlugField(unique=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
def __unicode__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Secret(models.Model):
|
||||
"""
|
||||
A secret string of up to 255 bytes in length, stored as both an AES256-encrypted ciphertext and an irreversible
|
||||
salted SHA256 hash (for plaintext validation).
|
||||
"""
|
||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||
object_id = models.PositiveIntegerField()
|
||||
parent = GenericForeignKey('content_type', 'object_id')
|
||||
role = models.ForeignKey('SecretRole', related_name='secrets', on_delete=models.PROTECT)
|
||||
name = models.CharField(max_length=100, blank=True)
|
||||
ciphertext = models.BinaryField(editable=False, max_length=65568) # 16B IV + 2B pad length + {62-65550}B padded
|
||||
hash = models.CharField(max_length=128, editable=False)
|
||||
created = models.DateTimeField(auto_now_add=True, editable=False, verbose_name='Created')
|
||||
last_modified = models.DateTimeField(auto_now=True, verbose_name='Last modified')
|
||||
|
||||
plaintext = None
|
||||
|
||||
class Meta:
|
||||
ordering = ['role', 'name']
|
||||
permissions = (
|
||||
('view_secret', "Can view secrets"),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.plaintext = kwargs.pop('plaintext', None)
|
||||
super(Secret, self).__init__(*args, **kwargs)
|
||||
|
||||
def __unicode__(self):
|
||||
if self.role and self.parent:
|
||||
return "{} for {}".format(self.role, self.parent)
|
||||
return "Secret"
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('secrets:secret', args=[self.pk])
|
||||
|
||||
def _pad(self, s):
|
||||
"""
|
||||
Prepend the length of the plaintext (2B) and pad with garbage to a multiple of 16B (minimum of 64B).
|
||||
+--+--------+-------------------------------------------+
|
||||
|LL|MySecret|xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx|
|
||||
+--+--------+-------------------------------------------+
|
||||
"""
|
||||
if len(s) > 65535:
|
||||
raise ValueError("Maximum plaintext size is 65535 bytes.")
|
||||
# Minimum ciphertext size is 64 bytes to conceal the length of short secrets.
|
||||
if len(s) <= 62:
|
||||
pad_length = 62 - len(s)
|
||||
elif (len(s) + 2) % 16:
|
||||
pad_length = 16 - ((len(s) + 2) % 16)
|
||||
else:
|
||||
pad_length = 0
|
||||
return chr(len(s) >> 8) + chr(len(s) % 256) + s + os.urandom(pad_length)
|
||||
|
||||
def _unpad(self, s):
|
||||
"""
|
||||
Consume the first two bytes of s as a plaintext length indicator and return only that many bytes as the
|
||||
plaintext.
|
||||
"""
|
||||
plaintext_length = (ord(s[0]) << 8) + ord(s[1])
|
||||
return s[2:plaintext_length + 2]
|
||||
|
||||
def encrypt(self, secret_key):
|
||||
"""
|
||||
Generate a random initialization vector (IV) for AES. Pad the plaintext to the AES block size (16 bytes) and
|
||||
encrypt. Prepend the IV for use in decryption. Finally, record the SHA256 hash of the plaintext for validation
|
||||
upon decryption.
|
||||
"""
|
||||
if self.plaintext is None:
|
||||
raise Exception("Must unlock or set plaintext before locking.")
|
||||
|
||||
# Pad and encrypt plaintext
|
||||
iv = os.urandom(16)
|
||||
aes = AES.new(secret_key, AES.MODE_CFB, iv)
|
||||
self.ciphertext = iv + aes.encrypt(self._pad(self.plaintext))
|
||||
|
||||
# Generate SHA256 using Django's built-in password hashing mechanism
|
||||
self.hash = make_password(self.plaintext, hasher='pbkdf2_sha256')
|
||||
|
||||
self.plaintext = None
|
||||
|
||||
def decrypt(self, secret_key):
|
||||
"""
|
||||
Consume the first 16 bytes of self.ciphertext as the AES initialization vector (IV). The remainder is decrypted
|
||||
using the IV and the provided secret key. Padding is then removed to reveal the plaintext. Finally, validate the
|
||||
decrypted plaintext value against the stored hash.
|
||||
"""
|
||||
if self.plaintext is not None:
|
||||
return
|
||||
if not self.ciphertext:
|
||||
raise Exception("Must define ciphertext before unlocking.")
|
||||
|
||||
# Decrypt ciphertext and remove padding
|
||||
iv = self.ciphertext[0:16]
|
||||
aes = AES.new(secret_key, AES.MODE_CFB, iv)
|
||||
plaintext = self._unpad(aes.decrypt(self.ciphertext[16:]))
|
||||
|
||||
# Verify decrypted plaintext against hash
|
||||
if not self.validate(plaintext):
|
||||
raise ValueError("Invalid key or ciphertext!")
|
||||
|
||||
self.plaintext = plaintext
|
||||
|
||||
def validate(self, plaintext):
|
||||
"""
|
||||
Validate that a given plaintext matches the stored hash.
|
||||
"""
|
||||
if not self.hash:
|
||||
raise Exception("Hash has not been generated for this secret.")
|
||||
return check_password(plaintext, self.hash)
|
||||
31
netbox/secrets/tables.py
Normal file
31
netbox/secrets/tables.py
Normal file
@@ -0,0 +1,31 @@
|
||||
import django_tables2 as tables
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from .models import Secret
|
||||
|
||||
|
||||
#
|
||||
# Secrets
|
||||
#
|
||||
|
||||
class SecretTable(tables.Table):
|
||||
parent = tables.LinkColumn('secrets:secret', args=[Accessor('pk')], verbose_name='Parent')
|
||||
role = tables.Column(verbose_name='Role')
|
||||
name = tables.Column(verbose_name='Name')
|
||||
last_modified = tables.DateTimeColumn(verbose_name='Last modified')
|
||||
|
||||
class Meta:
|
||||
model = Secret
|
||||
fields = ('parent', 'role', 'name', 'last_modified')
|
||||
empty_text = "No secrets found."
|
||||
attrs = {
|
||||
'class': 'table table-hover',
|
||||
}
|
||||
|
||||
|
||||
class SecretBulkEditTable(SecretTable):
|
||||
pk = tables.CheckBoxColumn()
|
||||
|
||||
class Meta(SecretTable.Meta):
|
||||
model = None # django_tables2 bugfix
|
||||
fields = ('pk', 'parent', 'role', 'name')
|
||||
12
netbox/secrets/templates/activate_keys.html
Normal file
12
netbox/secrets/templates/activate_keys.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{% extends "admin/base_site.html" %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<form action="." method="post">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p }}
|
||||
<input type="hidden" name="action" value="activate_selected" />
|
||||
<input type="submit" name="activate" value="Activate User Key(s)" />
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
||||
1
netbox/secrets/tests/__init__.py
Normal file
1
netbox/secrets/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from test_models import *
|
||||
131
netbox/secrets/tests/test_models.py
Normal file
131
netbox/secrets/tests/test_models.py
Normal file
@@ -0,0 +1,131 @@
|
||||
from Crypto.PublicKey import RSA
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase
|
||||
|
||||
from secrets.models import UserKey, Secret, generate_master_key, encrypt_master_key, decrypt_master_key
|
||||
|
||||
|
||||
class UserKeyTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.TEST_KEYS = {}
|
||||
key_size = getattr(settings, 'SECRETS_MIN_PUBKEY_SIZE', 2048)
|
||||
for username in ['alice', 'bob']:
|
||||
User.objects.create_user(username=username, password=username)
|
||||
key = RSA.generate(key_size)
|
||||
self.TEST_KEYS['{}_public'.format(username)] = key.publickey().exportKey('PEM')
|
||||
self.TEST_KEYS['{}_private'.format(username)] = key.exportKey('PEM')
|
||||
|
||||
def test_01_fill(self):
|
||||
"""
|
||||
Validate the filling of a UserKey with public key material.
|
||||
"""
|
||||
alice_uk = UserKey(user=User.objects.get(username='alice'))
|
||||
self.assertFalse(alice_uk.is_filled(), "UserKey with empty public_key is_filled() did not return False")
|
||||
alice_uk.public_key = self.TEST_KEYS['alice_public']
|
||||
self.assertTrue(alice_uk.is_filled(), "UserKey with public key is_filled() did not return True")
|
||||
|
||||
def test_02_activate(self):
|
||||
"""
|
||||
Validate the activation of a UserKey.
|
||||
"""
|
||||
master_key = generate_master_key()
|
||||
alice_uk = UserKey(user=User.objects.get(username='alice'), public_key=self.TEST_KEYS['alice_public'])
|
||||
self.assertFalse(alice_uk.is_active(), "Inactive UserKey is_active() did not return False")
|
||||
alice_uk.activate(master_key)
|
||||
self.assertTrue(alice_uk.is_active(), "ActiveUserKey is_active() did not return True")
|
||||
|
||||
def test_03_key_sizes(self):
|
||||
"""
|
||||
Ensure that RSA keys which are too small or too large are rejected.
|
||||
"""
|
||||
rsa = RSA.generate(getattr(settings, 'SECRETS_MIN_PUBKEY_SIZE', 2048) - 256)
|
||||
small_key = rsa.publickey().exportKey('PEM')
|
||||
try:
|
||||
UserKey(public_key=small_key).clean()
|
||||
self.fail("UserKey.clean() did not fail with an undersized RSA key")
|
||||
except ValidationError:
|
||||
pass
|
||||
rsa = RSA.generate(4096 + 256) # Max size is 4096 (enforced by master_key_cipher field size)
|
||||
big_key = rsa.publickey().exportKey('PEM')
|
||||
try:
|
||||
UserKey(public_key=big_key).clean()
|
||||
self.fail("UserKey.clean() did not fail with an oversized RSA key")
|
||||
except ValidationError:
|
||||
pass
|
||||
|
||||
def test_04_master_key_retrieval(self):
|
||||
"""
|
||||
Test the decryption of a master key using the user's private key.
|
||||
"""
|
||||
master_key = generate_master_key()
|
||||
alice_uk = UserKey(user=User.objects.get(username='alice'), public_key=self.TEST_KEYS['alice_public'])
|
||||
alice_uk.activate(master_key)
|
||||
retrieved_master_key = alice_uk.get_master_key(self.TEST_KEYS['alice_private'])
|
||||
self.assertEqual(master_key, retrieved_master_key, "Master key retrieval failed with correct private key")
|
||||
|
||||
def test_05_invalid_private_key(self):
|
||||
"""
|
||||
Ensure that an exception is raised when attempting to retrieve a secret key using an invalid private key.
|
||||
"""
|
||||
secret_key = generate_master_key()
|
||||
secret_key_cipher = encrypt_master_key(secret_key, self.TEST_KEYS['alice_public'])
|
||||
try:
|
||||
decrypted_secret_key = decrypt_master_key(secret_key_cipher, self.TEST_KEYS['bob_private'])
|
||||
self.fail("Decrypting secret key from Alice's UserKey using Bob's private key did not fail")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
class SecretTestCase(TestCase):
|
||||
|
||||
def test_01_encrypt_decrypt(self):
|
||||
"""
|
||||
Test basic encryption and decryption functionality using a random master key.
|
||||
"""
|
||||
plaintext = "FooBar123"
|
||||
secret_key = generate_master_key()
|
||||
s = Secret(plaintext=plaintext)
|
||||
s.encrypt(secret_key)
|
||||
|
||||
# Ensure plaintext is deleted upon encryption
|
||||
self.assertIsNone(s.plaintext, "Plaintext must be None after encrypting.")
|
||||
|
||||
# Enforce minimum ciphertext length
|
||||
self.assertGreaterEqual(len(s.ciphertext), 80, "Ciphertext must be at least 80 bytes (16B IV + 64B+ ciphertext")
|
||||
|
||||
# Ensure proper hashing algorithm is used
|
||||
hasher, iterations, salt, sha256 = s.hash.split('$')
|
||||
self.assertEqual(hasher, 'pbkdf2_sha256', "Hashing algorithm has been modified to: {}".format(hasher))
|
||||
self.assertGreaterEqual(iterations, 24000, "Insufficient iteration count ({}) for hash".format(iterations))
|
||||
self.assertGreaterEqual(len(salt), 12, "Hash salt is too short ({} chars)".format(len(salt)))
|
||||
|
||||
# Test hash validation
|
||||
self.assertTrue(s.validate(plaintext), "Plaintext does not validate against the generated hash")
|
||||
self.assertFalse(s.validate(""), "Empty plaintext validated against hash")
|
||||
self.assertFalse(s.validate("Invalid plaintext"), "Invalid plaintext validated against hash")
|
||||
|
||||
# Test decryption
|
||||
s.decrypt(secret_key)
|
||||
self.assertEqual(plaintext, s.plaintext, "Decrypting Secret returned incorrect plaintext")
|
||||
|
||||
def test_02_ciphertext_uniqueness(self):
|
||||
"""
|
||||
Generate 50 Secrets using the same plaintext and check for duplicate IVs or payloads.
|
||||
"""
|
||||
plaintext = "1234567890abcdef"
|
||||
secret_key = generate_master_key()
|
||||
ivs = []
|
||||
ciphertexts = []
|
||||
for i in range(1, 51):
|
||||
s = Secret(plaintext=plaintext)
|
||||
s.encrypt(secret_key)
|
||||
ivs.append(s.ciphertext[0:16])
|
||||
ciphertexts.append(s.ciphertext[16:32])
|
||||
duplicate_ivs = [i for i, x in enumerate(ivs) if ivs.count(x) > 1]
|
||||
self.assertEqual(duplicate_ivs, [], "One or more duplicate IVs found!")
|
||||
duplicate_ciphertexts = [i for i, x in enumerate(ciphertexts) if ciphertexts.count(x) > 1]
|
||||
self.assertEqual(duplicate_ciphertexts, [], "One or more duplicate ciphertexts (first blocks) found!")
|
||||
13
netbox/secrets/urls.py
Normal file
13
netbox/secrets/urls.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from django.conf.urls import url
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
url(r'^secrets/$', views.secret_list, name='secret_list'),
|
||||
url(r'^secrets/import/$', views.secret_import, name='secret_import'),
|
||||
url(r'^secrets/edit/$', views.SecretBulkEditView.as_view(), name='secret_bulk_edit'),
|
||||
url(r'^secrets/delete/$', views.SecretBulkDeleteView.as_view(), name='secret_bulk_delete'),
|
||||
url(r'^secrets/(?P<pk>\d+)/$', views.secret, name='secret'),
|
||||
url(r'^secrets/(?P<pk>\d+)/edit/$', views.secret_edit, name='secret_edit'),
|
||||
url(r'^secrets/(?P<pk>\d+)/delete/$', views.secret_delete, name='secret_delete'),
|
||||
]
|
||||
235
netbox/secrets/views.py
Normal file
235
netbox/secrets/views.py
Normal file
@@ -0,0 +1,235 @@
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import permission_required, login_required
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.db import transaction, IntegrityError
|
||||
from django.db.models import ProtectedError
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
|
||||
from django_tables2 import RequestConfig
|
||||
from utilities.error_handlers import handle_protectederror
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.paginator import EnhancedPaginator
|
||||
from utilities.views import BulkEditView, BulkDeleteView
|
||||
|
||||
from .decorators import userkey_required
|
||||
from .filters import SecretFilter
|
||||
from .forms import SecretForm, SecretImportForm, SecretBulkEditForm, SecretBulkDeleteForm, SecretFilterForm
|
||||
from .models import Secret, UserKey
|
||||
from .tables import SecretTable, SecretBulkEditTable
|
||||
|
||||
|
||||
#
|
||||
# Secrets
|
||||
#
|
||||
|
||||
@login_required
|
||||
def secret_list(request):
|
||||
|
||||
queryset = Secret.objects.select_related('role').prefetch_related('parent')
|
||||
queryset = SecretFilter(request.GET, queryset).qs
|
||||
|
||||
if request.user.has_perm('secrets.change_secret') or request.user.has_perm('secrets.delete_secret'):
|
||||
secret_table = SecretBulkEditTable(queryset)
|
||||
else:
|
||||
secret_table = SecretTable(queryset)
|
||||
RequestConfig(request, paginate={'per_page': settings.PAGINATE_COUNT, 'klass': EnhancedPaginator})\
|
||||
.configure(secret_table)
|
||||
|
||||
return render(request, 'secrets/secret_list.html', {
|
||||
'secret_table': secret_table,
|
||||
'filter_form': SecretFilterForm(request.GET, label_suffix=''),
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def secret(request, pk):
|
||||
|
||||
secret = get_object_or_404(Secret, pk=pk)
|
||||
|
||||
return render(request, 'secrets/secret.html', {
|
||||
'secret': secret,
|
||||
})
|
||||
|
||||
|
||||
@permission_required('secrets.add_secret')
|
||||
@userkey_required()
|
||||
def secret_add(request, parent_model, parent_pk):
|
||||
|
||||
# Retrieve parent object
|
||||
parent_cls = apps.get_model(parent_model)
|
||||
parent = get_object_or_404(parent_cls, pk=parent_pk)
|
||||
|
||||
secret = Secret(parent=parent)
|
||||
uk = UserKey.objects.get(user=request.user)
|
||||
|
||||
if request.method == 'POST':
|
||||
form = SecretForm(request.POST, instance=secret)
|
||||
if form.is_valid():
|
||||
|
||||
# Retrieve the master key from the current user's UserKey
|
||||
master_key = uk.get_master_key(form.cleaned_data['private_key'])
|
||||
if master_key is None:
|
||||
form.add_error(None, "Invalid private key! Unable to encrypt secret data.")
|
||||
|
||||
# Create and encrypt the new Secret
|
||||
else:
|
||||
secret = form.save(commit=False)
|
||||
secret.plaintext = str(form.cleaned_data['plaintext'])
|
||||
secret.encrypt(master_key)
|
||||
secret.save()
|
||||
|
||||
messages.success(request, "Added new secret: {0}".format(secret))
|
||||
if '_addanother' in request.POST:
|
||||
return redirect('secrets:secret_add')
|
||||
else:
|
||||
return redirect('secrets:secret', pk=secret.pk)
|
||||
|
||||
else:
|
||||
form = SecretForm(instance=secret)
|
||||
|
||||
return render(request, 'secrets/secret_edit.html', {
|
||||
'secret': secret,
|
||||
'form': form,
|
||||
'cancel_url': parent.get_absolute_url(),
|
||||
})
|
||||
|
||||
|
||||
@permission_required('secrets.change_secret')
|
||||
@userkey_required()
|
||||
def secret_edit(request, pk):
|
||||
|
||||
secret = get_object_or_404(Secret, pk=pk)
|
||||
uk = UserKey.objects.get(user=request.user)
|
||||
|
||||
if request.method == 'POST':
|
||||
form = SecretForm(request.POST, instance=secret)
|
||||
if form.is_valid():
|
||||
|
||||
# Re-encrypt the Secret if a plaintext has been specified.
|
||||
if form.cleaned_data['plaintext']:
|
||||
|
||||
# Retrieve the master key from the current user's UserKey
|
||||
master_key = uk.get_master_key(form.cleaned_data['private_key'])
|
||||
if master_key is None:
|
||||
form.add_error(None, "Invalid private key! Unable to encrypt secret data.")
|
||||
|
||||
# Create and encrypt the new Secret
|
||||
else:
|
||||
secret = form.save(commit=False)
|
||||
secret.plaintext = str(form.cleaned_data['plaintext'])
|
||||
secret.encrypt(master_key)
|
||||
secret.save()
|
||||
|
||||
else:
|
||||
secret = form.save()
|
||||
|
||||
messages.success(request, "Modified secret {0}".format(secret))
|
||||
return redirect('secrets:secret', pk=secret.pk)
|
||||
|
||||
else:
|
||||
form = SecretForm(instance=secret)
|
||||
|
||||
return render(request, 'secrets/secret_edit.html', {
|
||||
'secret': secret,
|
||||
'form': form,
|
||||
'cancel_url': reverse('secrets:secret', kwargs={'pk': secret.pk}),
|
||||
})
|
||||
|
||||
|
||||
@permission_required('secrets.delete_secret')
|
||||
def secret_delete(request, pk):
|
||||
|
||||
secret = get_object_or_404(Secret, pk=pk)
|
||||
|
||||
if request.method == 'POST':
|
||||
form = ConfirmationForm(request.POST)
|
||||
if form.is_valid():
|
||||
try:
|
||||
secret.delete()
|
||||
messages.success(request, "Secret {0} has been deleted".format(secret))
|
||||
return redirect('secrets:secret_list')
|
||||
except ProtectedError, e:
|
||||
handle_protectederror(secret, request, e)
|
||||
return redirect('secrets:secret', pk=secret.pk)
|
||||
|
||||
else:
|
||||
form = ConfirmationForm()
|
||||
|
||||
return render(request, 'secrets/secret_delete.html', {
|
||||
'secret': secret,
|
||||
'form': form,
|
||||
'cancel_url': reverse('secrets:secret', kwargs={'pk': secret.pk})
|
||||
})
|
||||
|
||||
|
||||
@permission_required('secrets.add_secret')
|
||||
@userkey_required()
|
||||
def secret_import(request):
|
||||
|
||||
uk = UserKey.objects.get(user=request.user)
|
||||
|
||||
if request.method == 'POST':
|
||||
form = SecretImportForm(request.POST)
|
||||
if form.is_valid():
|
||||
|
||||
new_secrets = []
|
||||
|
||||
# Retrieve the master key from the current user's UserKey
|
||||
master_key = uk.get_master_key(form.cleaned_data['private_key'])
|
||||
if master_key is None:
|
||||
form.add_error(None, "Invalid private key! Unable to encrypt secret data.")
|
||||
|
||||
else:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
for secret in form.cleaned_data['csv']:
|
||||
secret.encrypt(master_key)
|
||||
secret.save()
|
||||
new_secrets.append(secret)
|
||||
|
||||
secret_table = SecretTable(new_secrets)
|
||||
messages.success(request, "Imported {} new secrets".format(len(new_secrets)))
|
||||
|
||||
return render(request, 'import_success.html', {
|
||||
'secret_table': secret_table,
|
||||
})
|
||||
|
||||
except IntegrityError as e:
|
||||
form.add_error('csv', "Record {}: {}".format(len(new_secrets) + 1, e.__cause__))
|
||||
|
||||
else:
|
||||
form = SecretImportForm()
|
||||
|
||||
return render(request, 'secrets/secret_import.html', {
|
||||
'form': form,
|
||||
'cancel_url': reverse('secrets:secret_list'),
|
||||
})
|
||||
|
||||
|
||||
class SecretBulkEditView(PermissionRequiredMixin, BulkEditView):
|
||||
permission_required = 'secrets.change_secret'
|
||||
cls = Secret
|
||||
form = SecretBulkEditForm
|
||||
template_name = 'secrets/secret_bulk_edit.html'
|
||||
redirect_url = 'secrets:secret_list'
|
||||
|
||||
def update_objects(self, pk_list, form):
|
||||
|
||||
fields_to_update = {}
|
||||
for field in ['role', 'name']:
|
||||
if form.cleaned_data[field]:
|
||||
fields_to_update[field] = form.cleaned_data[field]
|
||||
|
||||
updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
|
||||
messages.success(self.request, "Updated {} secrets".format(updated_count))
|
||||
|
||||
|
||||
class SecretBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'secrets.delete_secret'
|
||||
cls = Secret
|
||||
form = SecretBulkDeleteForm
|
||||
template_name = 'secrets/secret_bulk_delete.html'
|
||||
redirect_url = 'secrets:secret_list'
|
||||
Reference in New Issue
Block a user