Initial push to public repo

This commit is contained in:
Jeremy Stretch
2016-03-01 11:23:03 -05:00
commit 27b289ee3b
281 changed files with 26061 additions and 0 deletions

View File

71
netbox/secrets/admin.py Normal file
View 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']

View File

View 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']

View 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
View 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
View File

@@ -0,0 +1,7 @@
from __future__ import unicode_literals
from django.apps import AppConfig
class SecretsConfig(AppConfig):
name = 'secrets'

View 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
View 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
View 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'}))

View 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'),
),
]

View File

282
netbox/secrets/models.py Normal file
View 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
View 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')

View 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 %}

View File

@@ -0,0 +1 @@
from test_models import *

View 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
View 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
View 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'