Commit 8f572e53 by David Ormsbee

Flesh out the Software Secure model and add SS-specific encryption functions

parent 086f5564
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Models for Student Identity Verification Models for Student Identity Verification
Currently the only model is `PhotoVerificationAttempt`, but this is where we This is where we put any models relating to establishing the real-life identity
would put any models relating to establishing the real-life identity of a of a student over a period of time. Right now, the only models are the abstract
student over a period of time. `PhotoVerificationAttempt`, and its one concrete implementation
`SoftwareSecurePhotoVerificationAttempt`. The hope is to keep as much of the
photo verification process as generic as possible.
""" """
from datetime import datetime from datetime import datetime
from hashlib import md5
import base64
import functools import functools
import logging import logging
import uuid import uuid
import pytz import pytz
from django.db import models from django.db import models
from django.contrib.auth.models import User from django.contrib.auth.models import User
from model_utils.models import StatusModel from model_utils.models import StatusModel
from model_utils import Choices from model_utils import Choices
from verify_student.ssencrypt import (
random_aes_key, decode_and_decrypt, encrypt_and_encode
)
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -25,16 +33,9 @@ class VerificationException(Exception): ...@@ -25,16 +33,9 @@ class VerificationException(Exception):
pass pass
class IdVerifiedCourses(models.Model):
"""
A table holding all the courses that are eligible for ID Verification.
"""
course_id = models.CharField(blank=False, max_length=100)
def status_before_must_be(*valid_start_statuses): def status_before_must_be(*valid_start_statuses):
""" """
Decorator with arguments to make sure that an object with a `status` Helper decorator with arguments to make sure that an object with a `status`
attribute is in one of a list of acceptable status states before a method attribute is in one of a list of acceptable status states before a method
is called. You could use it in a class definition like: is called. You could use it in a class definition like:
...@@ -68,7 +69,7 @@ class PhotoVerificationAttempt(StatusModel): ...@@ -68,7 +69,7 @@ class PhotoVerificationAttempt(StatusModel):
their identity by uploading a photo of themselves and a picture ID. An their identity by uploading a photo of themselves and a picture ID. An
attempt actually has a number of fields that need to be filled out at attempt actually has a number of fields that need to be filled out at
different steps of the approval process. While it's useful as a Django Model different steps of the approval process. While it's useful as a Django Model
for the querying facilities, **you should only create and edit a for the querying facilities, **you should only create and edit a
`PhotoVerificationAttempt` object through the methods provided**. Do not `PhotoVerificationAttempt` object through the methods provided**. Do not
just construct one and start setting fields unless you really know what just construct one and start setting fields unless you really know what
you're doing. you're doing.
...@@ -89,12 +90,12 @@ class PhotoVerificationAttempt(StatusModel): ...@@ -89,12 +90,12 @@ class PhotoVerificationAttempt(StatusModel):
photo ID match up, and that the photo ID's name matches the user's. photo ID match up, and that the photo ID's name matches the user's.
`denied` `denied`
The request has been denied. See `error_msg` for details on why. An The request has been denied. See `error_msg` for details on why. An
admin might later override this and change to `approved`, but the admin might later override this and change to `approved`, but the
student cannot re-open this attempt -- they have to create another student cannot re-open this attempt -- they have to create another
attempt and submit it instead. attempt and submit it instead.
Because this Model inherits from StatusModel, we can also do things like:: Because this Model inherits from StatusModel, we can also do things like::
attempt.status == PhotoVerificationAttempt.STATUS.created attempt.status == PhotoVerificationAttempt.STATUS.created
attempt.status == "created" attempt.status == "created"
pending_requests = PhotoVerificationAttempt.submitted.all() pending_requests = PhotoVerificationAttempt.submitted.all()
...@@ -126,11 +127,9 @@ class PhotoVerificationAttempt(StatusModel): ...@@ -126,11 +127,9 @@ class PhotoVerificationAttempt(StatusModel):
created_at = models.DateTimeField(auto_now_add=True, db_index=True) created_at = models.DateTimeField(auto_now_add=True, db_index=True)
updated_at = models.DateTimeField(auto_now=True, db_index=True) updated_at = models.DateTimeField(auto_now=True, db_index=True)
######################## Fields Set When Submitting ######################## ######################## Fields Set When Submitting ########################
submitted_at = models.DateTimeField(null=True, db_index=True) submitted_at = models.DateTimeField(null=True, db_index=True)
#################### Fields Set During Approval/Denial ##################### #################### Fields Set During Approval/Denial #####################
# If the review was done by an internal staff member, mark who it was. # If the review was done by an internal staff member, mark who it was.
reviewing_user = models.ForeignKey( reviewing_user = models.ForeignKey(
...@@ -153,6 +152,8 @@ class PhotoVerificationAttempt(StatusModel): ...@@ -153,6 +152,8 @@ class PhotoVerificationAttempt(StatusModel):
# capturing it so that we can later query for the common problems. # capturing it so that we can later query for the common problems.
error_code = models.CharField(blank=True, max_length=50) error_code = models.CharField(blank=True, max_length=50)
class Meta:
abstract = True
##### Methods listed in the order you'd typically call them ##### Methods listed in the order you'd typically call them
@classmethod @classmethod
...@@ -162,7 +163,6 @@ class PhotoVerificationAttempt(StatusModel): ...@@ -162,7 +163,6 @@ class PhotoVerificationAttempt(StatusModel):
time, so a user might have to renew periodically.""" time, so a user might have to renew periodically."""
raise NotImplementedError raise NotImplementedError
@classmethod @classmethod
def active_for_user(cls, user_id): def active_for_user(cls, user_id):
"""Return all PhotoVerificationAttempts that are still active (i.e. not """Return all PhotoVerificationAttempts that are still active (i.e. not
...@@ -173,17 +173,14 @@ class PhotoVerificationAttempt(StatusModel): ...@@ -173,17 +173,14 @@ class PhotoVerificationAttempt(StatusModel):
""" """
raise NotImplementedError raise NotImplementedError
@status_before_must_be("created") @status_before_must_be("created")
def upload_face_image(self, img): def upload_face_image(self, img):
raise NotImplementedError raise NotImplementedError
@status_before_must_be("created") @status_before_must_be("created")
def upload_photo_id_image(self, img): def upload_photo_id_image(self, img):
raise NotImplementedError raise NotImplementedError
@status_before_must_be("created") @status_before_must_be("created")
def mark_ready(self): def mark_ready(self):
""" """
...@@ -215,7 +212,7 @@ class PhotoVerificationAttempt(StatusModel): ...@@ -215,7 +212,7 @@ class PhotoVerificationAttempt(StatusModel):
raise VerificationException("No face image was uploaded.") raise VerificationException("No face image was uploaded.")
if not self.photo_id_image_url: if not self.photo_id_image_url:
raise VerificationException("No photo ID image was uploaded.") raise VerificationException("No photo ID image was uploaded.")
# At any point prior to this, they can change their names via their # At any point prior to this, they can change their names via their
# student dashboard. But at this point, we lock the value into the # student dashboard. But at this point, we lock the value into the
# attempt. # attempt.
...@@ -223,7 +220,6 @@ class PhotoVerificationAttempt(StatusModel): ...@@ -223,7 +220,6 @@ class PhotoVerificationAttempt(StatusModel):
self.status = "ready" self.status = "ready"
self.save() self.save()
@status_before_must_be("ready", "submit") @status_before_must_be("ready", "submit")
def submit(self, reviewing_service=None): def submit(self, reviewing_service=None):
if self.status == "submitted": if self.status == "submitted":
...@@ -235,7 +231,6 @@ class PhotoVerificationAttempt(StatusModel): ...@@ -235,7 +231,6 @@ class PhotoVerificationAttempt(StatusModel):
self.status = "submitted" self.status = "submitted"
self.save() self.save()
@status_before_must_be("submitted", "approved", "denied") @status_before_must_be("submitted", "approved", "denied")
def approve(self, user_id=None, service=""): def approve(self, user_id=None, service=""):
""" """
...@@ -268,15 +263,14 @@ class PhotoVerificationAttempt(StatusModel): ...@@ -268,15 +263,14 @@ class PhotoVerificationAttempt(StatusModel):
# If someone approves an outdated version of this, the first one wins # If someone approves an outdated version of this, the first one wins
if self.status == "approved": if self.status == "approved":
return return
self.error_msg = "" # reset, in case this attempt was denied before self.error_msg = "" # reset, in case this attempt was denied before
self.error_code = "" # reset, in case this attempt was denied before self.error_code = "" # reset, in case this attempt was denied before
self.reviewing_user = user_id self.reviewing_user = user_id
self.reviewing_service = service self.reviewing_service = service
self.status = "approved" self.status = "approved"
self.save() self.save()
@status_before_must_be("submitted", "approved", "denied") @status_before_must_be("submitted", "approved", "denied")
def deny(self, def deny(self,
error_msg, error_msg,
...@@ -292,7 +286,8 @@ class PhotoVerificationAttempt(StatusModel): ...@@ -292,7 +286,8 @@ class PhotoVerificationAttempt(StatusModel):
Status after method completes: `denied` Status after method completes: `denied`
Other fields that will be set by this method: Other fields that will be set by this method:
`reviewed_by_user_id`, `reviewed_by_service`, `error_msg`, `error_code` `reviewed_by_user_id`, `reviewed_by_service`, `error_msg`,
`error_code`
State Transitions: State Transitions:
...@@ -320,3 +315,57 @@ class PhotoVerificationAttempt(StatusModel): ...@@ -320,3 +315,57 @@ class PhotoVerificationAttempt(StatusModel):
self.save() self.save()
class SoftwareSecurePhotoVerificationAttempt(PhotoVerificationAttempt):
"""
Model to verify identity using a service provided by Software Secure. Much
of the logic is inherited from `PhotoVerificationAttempt`, but this class
encrypts the photos.
Software Secure (http://www.softwaresecure.com/) is a remote proctoring
service that also does identity verification. A student uses their webcam
to upload two images: one of their face, one of a photo ID. Due to the
sensitive nature of the data, the following security precautions are taken:
1. The snapshot of their face is encrypted using AES-256 in CBC mode. All
face photos are encypted with the same key, and this key is known to
both Software Secure and edx-platform.
2. The snapshot of a user's photo ID is also encrypted using AES-256, but
the key is randomly generated using pycrypto's Random. Every verification
attempt has a new key. The AES key is then encrypted using a public key
provided by Software Secure. We store only the RSA-encryped AES key.
Since edx-platform does not have Software Secure's private RSA key, it
means that we can no longer even read photo ID.
3. The encrypted photos are base64 encoded and stored in an S3 bucket that
edx-platform does not have read access to.
"""
# This is a base64.urlsafe_encode(rsa_encrypt(photo_id_aes_key), ss_pub_key)
# So first we generate a random AES-256 key to encrypt our photo ID with.
# Then we RSA encrypt it with Software Secure's public key. Then we base64
# encode that. The result is saved here. Actual expected length is 344.
photo_id_key = models.TextField(max_length=1024)
@status_before_must_be("created")
def upload_face_image(self, img_data):
aes_key_str = settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["FACE_IMAGE_AES_KEY"]
aes_key = aes_key_str.decode("hex")
encrypted_img_data = self._encrypt_image_data(img_data, aes_key)
b64_encoded_img_data = base64.encodestring(encrypted_img_data)
# Upload it to S3
@status_before_must_be("created")
def upload_photo_id_image(self, img_data):
aes_key = random_aes_key()
encrypted_img_data = self._encrypt_image_data(img_data, aes_key)
b64_encoded_img_data = base64.encodestring(encrypted_img_data)
# Upload this to S3
rsa_key = RSA.importKey(
settings.VERIFY_STUDENT["SOFTWARE_SECURE"]["RSA_PUBLIC_KEY"]
)
rsa_cipher = PKCS1_OAEP.new(key)
rsa_encrypted_aes_key = rsa_cipher.encrypt(aes_key)
"""
NOTE: Anytime a `key` is passed into a function here, we assume it's a raw byte
string. It should *not* be a string representation of a hex value. In other
words, passing the `str` value of
`"32fe72aaf2abb44de9e161131b5435c8d37cbdb6f5df242ae860b283115f2dae"` is bad.
You want to pass in the result of calling .decode('hex') on that, so this instead:
"'2\xfer\xaa\xf2\xab\xb4M\xe9\xe1a\x13\x1bT5\xc8\xd3|\xbd\xb6\xf5\xdf$*\xe8`\xb2\x83\x11_-\xae'"
The RSA functions take any key format that RSA.importKey() accepts, so...
An RSA public key can be in any of the following formats:
* X.509 subjectPublicKeyInfo DER SEQUENCE (binary or PEM encoding)
* PKCS#1 RSAPublicKey DER SEQUENCE (binary or PEM encoding)
* OpenSSH (textual public key only)
An RSA private key can be in any of the following formats:
* PKCS#1 RSAPrivateKey DER SEQUENCE (binary or PEM encoding)
* PKCS#8 PrivateKeyInfo DER SEQUENCE (binary or PEM encoding)
* OpenSSH (textual public key only)
In case of PEM encoding, the private key can be encrypted with DES or 3TDES
according to a certain pass phrase. Only OpenSSL-compatible pass phrases are
supported.
"""
from hashlib import md5
import base64
from Crypto import Random
from Crypto.Cipher import AES, PKCS1_OAEP
from Crypto.PublicKey import RSA
def encrypt_and_encode(data, key):
return base64.urlsafe_b64encode(aes_encrypt(data, key))
def decode_and_decrypt(encoded_data, key):
return aes_decrypt(base64.urlsafe_b64decode(encoded_data), key)
def aes_encrypt(data, key):
"""
Return a version of the `data` that has been encrypted to
"""
cipher = aes_cipher_from_key(key)
padded_data = pad(data)
return cipher.encrypt(padded_data)
def aes_decrypt(encrypted_data, key):
cipher = aes_cipher_from_key(key)
padded_data = cipher.decrypt(encrypted_data)
return unpad(padded_data)
def aes_cipher_from_key(key):
"""
Given an AES key, return a Cipher object that has `encrypt()` and
`decrypt()` methods. It will create the cipher to use CBC mode, and create
the initialization vector as Software Secure expects it.
"""
return AES.new(key, AES.MODE_CBC, generate_aes_iv(key))
def generate_aes_iv(key):
"""
Return the initialization vector Software Secure expects for a given AES
key (they hash it a couple of times and take a substring).
"""
return md5(key + md5(key).hexdigest()).hexdigest()[:AES.block_size]
def random_aes_key():
return Random.new().read(32)
def pad(data):
bytes_to_pad = AES.block_size - len(data) % AES.block_size
return data + (bytes_to_pad * chr(bytes_to_pad))
def unpad(padded_data):
num_padded_bytes = ord(padded_data[-1])
return padded_data[:-num_padded_bytes]
def rsa_encrypt(data, rsa_pub_key_str):
"""
`rsa_pub_key` is a string with the public key
"""
key = RSA.importKey(rsa_pub_key_str)
cipher = PKCS1_OAEP.new(key)
encrypted_data = cipher.encrypt(data)
return encrypted_data
def rsa_decrypt(data, rsa_priv_key_str):
key = RSA.importKey(rsa_priv_key_str)
cipher = PKCS1_OAEP.new(key)
return cipher.decrypt(data)
import base64
from nose.tools import assert_equals
from verify_student.ssencrypt import (
aes_decrypt, aes_encrypt, encrypt_and_encode, decode_and_decrypt,
rsa_decrypt, rsa_encrypt
)
def test_aes():
key_str = "32fe72aaf2abb44de9e161131b5435c8d37cbdb6f5df242ae860b283115f2dae"
key = key_str.decode("hex")
def assert_roundtrip(text):
assert_equals(text, aes_decrypt(aes_encrypt(text, key), key))
assert_equals(
text,
decode_and_decrypt(
encrypt_and_encode(text, key),
key
)
)
assert_roundtrip("Hello World!")
assert_roundtrip("1234567890123456") # AES block size, padding corner case
# Longer string
assert_roundtrip("12345678901234561234567890123456123456789012345601")
assert_roundtrip("")
assert_roundtrip("\xe9\xe1a\x13\x1bT5\xc8") # Random, non-ASCII text
def test_rsa():
# Make up some garbage keys for testing purposes.
pub_key_str = """-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1hLVjP0oV0Uy/+jQ+Upz
c+eYc4Pyflb/WpfgYATggkoQdnsdplmvPtQr85+utgqKPxOh+PvYGW8QNUzjLIu4
5/GlmvBa82i1jRMgEAxGI95bz7j9DtH+7mnj+06zR5xHwT49jK0zMs5MjMaz5WRq
BUNkz7dxWzDrYJZQx230sPp6upy1Y5H5O8SnJVdghsh8sNciS4Bo4ZONQ3giBwxz
h5svjspz1MIsOoShjbAdfG+4VX7sVwYlw2rnQeRsMH5/xpnNeqtScyOMoz0N9UDG
dtRMNGa2MihAg7zh7/zckbUrtf+o5wQtlCJL1Kdj4EjshqYvCxzWnSM+MaYAjb3M
EQIDAQAB
-----END PUBLIC KEY-----"""
priv_key_str = """-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA1hLVjP0oV0Uy/+jQ+Upzc+eYc4Pyflb/WpfgYATggkoQdnsd
plmvPtQr85+utgqKPxOh+PvYGW8QNUzjLIu45/GlmvBa82i1jRMgEAxGI95bz7j9
DtH+7mnj+06zR5xHwT49jK0zMs5MjMaz5WRqBUNkz7dxWzDrYJZQx230sPp6upy1
Y5H5O8SnJVdghsh8sNciS4Bo4ZONQ3giBwxzh5svjspz1MIsOoShjbAdfG+4VX7s
VwYlw2rnQeRsMH5/xpnNeqtScyOMoz0N9UDGdtRMNGa2MihAg7zh7/zckbUrtf+o
5wQtlCJL1Kdj4EjshqYvCxzWnSM+MaYAjb3MEQIDAQABAoIBAQCviuA87fdfoOoS
OerrEacc20QDLaby/QoGUtZ2RmmHzY40af7FQ3PWFIw6Ca5trrTwxnuivXnWWWG0
I2mCRM0Kvfgr1n7ubOW7WnyHTFlT3mnxK2Ov/HmNLZ36nO2cgkXA6/Xy3rBGMC9L
nUE1kSLzT/Fh965ntfS9zmVNNBhb6no0rVkGx5nK3vTI6kUmaa0m+E7KL/HweO4c
JodhN8CX4gpxSrkuwJ7IHEPYspqc0jInMYKLmD3d2g3BiOctjzFmaj3lV5AUlujW
z7/LVe5WAEaaxjwaMvwqrJLv9ogxWU3etJf22+Yy7r5gbPtqpqJrCZ5+WpGnUHws
3mMGP2QBAoGBAOc3pzLFgGUREVPSFQlJ06QFtfKYqg9fFHJCgWu/2B2aVZc2aO/t
Zhuoz+AgOdzsw+CWv7K0FH9sUkffk2VKPzwwwufLK3avD9gI0bhmBAYvdhS6A3nO
YM3W+lvmaJtFL00K6kdd+CzgRnBS9cZ70WbcbtqjdXI6+mV1WdGUTLhBAoGBAO0E
xhD4z+GjubSgfHYEZPgRJPqyUIfDH+5UmFGpr6zlvNN/depaGxsbhW8t/V6xkxsG
MCgic7GLMihEiUMx1+/snVs5bBUx7OT9API0d+vStHCFlTTe6aTdmiduFD4PbDsq
6E4DElVRqZhpIYusdDh7Z3fO2hm5ad4FfMlx65/RAoGAPYEfV7ETs06z9kEG2X6q
7pGaUZrsecRH8xDfzmKswUshg2S0y0WyCJ+CFFNeMPdGL4LKIWYnobGVvYqqcaIr
af5qijAQMrTkmQnXh56TaXXMijzk2czdEUQjOrjykIL5zxudMDi94GoUMqLOv+qF
zD/MuRoMDsPDgaOSrd4t/kECgYEAzwBNT8NOIz3P0Z4cNSJPYIvwpPaY+IkE2SyO
vzuYj0Mx7/Ew9ZTueXVGyzv6PfqOhJqZ8mNscZIlIyAAVWwxsHwRTfvPlo882xzP
97i1R4OFTYSNNFi+69sSZ/9utGjZ2K73pjJuj487tD2VK5xZAH9edTd2KeNSP7LB
MlpJNBECgYAmIswPdldm+G8SJd5j9O2fcDVTURjKAoSXCv2j4gEZzzfudpLWNHYu
l8N6+LEIVTMAytPk+/bImHvGHKZkCz5rEMSuYJWOmqKI92rUtI6fz5DUb3XSbrwT
3W+sdGFUK3GH1NAX71VxbAlFVLUetcMwai1+wXmGkRw6A7YezVFnhw==
-----END RSA PRIVATE KEY-----"""
aes_key_str = "32fe72aaf2abb44de9e161131b5435c8d37cbdb6f5df242ae860b283115f2dae"
aes_key = aes_key_str.decode('hex')
encrypted_aes_key = rsa_encrypt(aes_key, pub_key_str)
assert_equals(aes_key, rsa_decrypt(encrypted_aes_key, priv_key_str))
# Even though our AES key is only 32 bytes, RSA encryption will make it 256
# bytes, and base64 encoding will blow that up to 344
assert_equals(len(base64.urlsafe_b64encode(encrypted_aes_key)), 344)
# Software Secure would decrypt our photo_id image by doing:
#rsa_encrypted_aes_key = base64.urlsafe_b64decode(encoded_photo_id_key)
#photo_id_aes_key = rsa_decrypt(rsa_encrypted_aes_key, priv_key_str)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment