Commit 8f572e53 by David Ormsbee

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

parent 086f5564
......@@ -2,22 +2,30 @@
"""
Models for Student Identity Verification
Currently the only model is `PhotoVerificationAttempt`, but this is where we
would put any models relating to establishing the real-life identity of a
student over a period of time.
This is where we put any models relating to establishing the real-life identity
of a student over a period of time. Right now, the only models are the abstract
`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 hashlib import md5
import base64
import functools
import logging
import uuid
import pytz
from django.db import models
from django.contrib.auth.models import User
from model_utils.models import StatusModel
from model_utils import Choices
from verify_student.ssencrypt import (
random_aes_key, decode_and_decrypt, encrypt_and_encode
)
log = logging.getLogger(__name__)
......@@ -25,16 +33,9 @@ class VerificationException(Exception):
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):
"""
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
is called. You could use it in a class definition like:
......@@ -126,11 +127,9 @@ class PhotoVerificationAttempt(StatusModel):
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
updated_at = models.DateTimeField(auto_now=True, db_index=True)
######################## Fields Set When Submitting ########################
submitted_at = models.DateTimeField(null=True, db_index=True)
#################### Fields Set During Approval/Denial #####################
# If the review was done by an internal staff member, mark who it was.
reviewing_user = models.ForeignKey(
......@@ -153,6 +152,8 @@ class PhotoVerificationAttempt(StatusModel):
# capturing it so that we can later query for the common problems.
error_code = models.CharField(blank=True, max_length=50)
class Meta:
abstract = True
##### Methods listed in the order you'd typically call them
@classmethod
......@@ -162,7 +163,6 @@ class PhotoVerificationAttempt(StatusModel):
time, so a user might have to renew periodically."""
raise NotImplementedError
@classmethod
def active_for_user(cls, user_id):
"""Return all PhotoVerificationAttempts that are still active (i.e. not
......@@ -173,17 +173,14 @@ class PhotoVerificationAttempt(StatusModel):
"""
raise NotImplementedError
@status_before_must_be("created")
def upload_face_image(self, img):
raise NotImplementedError
@status_before_must_be("created")
def upload_photo_id_image(self, img):
raise NotImplementedError
@status_before_must_be("created")
def mark_ready(self):
"""
......@@ -223,7 +220,6 @@ class PhotoVerificationAttempt(StatusModel):
self.status = "ready"
self.save()
@status_before_must_be("ready", "submit")
def submit(self, reviewing_service=None):
if self.status == "submitted":
......@@ -235,7 +231,6 @@ class PhotoVerificationAttempt(StatusModel):
self.status = "submitted"
self.save()
@status_before_must_be("submitted", "approved", "denied")
def approve(self, user_id=None, service=""):
"""
......@@ -276,7 +271,6 @@ class PhotoVerificationAttempt(StatusModel):
self.status = "approved"
self.save()
@status_before_must_be("submitted", "approved", "denied")
def deny(self,
error_msg,
......@@ -292,7 +286,8 @@ class PhotoVerificationAttempt(StatusModel):
Status after method completes: `denied`
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:
......@@ -320,3 +315,57 @@ class PhotoVerificationAttempt(StatusModel):
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