Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
E
edx-platform
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
edx
edx-platform
Commits
8f572e53
Commit
8f572e53
authored
Aug 19, 2013
by
David Ormsbee
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Flesh out the Software Secure model and add SS-specific encryption functions
parent
086f5564
Hide whitespace changes
Inline
Side-by-side
Showing
3 changed files
with
251 additions
and
29 deletions
+251
-29
lms/djangoapps/verify_student/models.py
+78
-29
lms/djangoapps/verify_student/ssencrypt.py
+90
-0
lms/djangoapps/verify_student/tests/test_ssencrypt.py
+83
-0
No files found.
lms/djangoapps/verify_student/models.py
View file @
8f572e53
# -*- coding: utf-8 -*-
# -*- coding: utf-8 -*-
"""
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
):
"""
D
ecorator with arguments to make sure that an object with a `status`
Helper d
ecorator 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:
...
...
@@ -68,7 +69,7 @@ class PhotoVerificationAttempt(StatusModel):
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
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
just construct one and start setting fields unless you really know what
you're doing.
...
...
@@ -89,12 +90,12 @@ class PhotoVerificationAttempt(StatusModel):
photo ID match up, and that the photo ID's name matches the user's.
`denied`
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
attempt and submit it instead.
Because this Model inherits from StatusModel, we can also do things like::
attempt.status == PhotoVerificationAttempt.STATUS.created
attempt.status == "created"
pending_requests = PhotoVerificationAttempt.submitted.all()
...
...
@@ -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
):
"""
...
...
@@ -215,7 +212,7 @@ class PhotoVerificationAttempt(StatusModel):
raise
VerificationException
(
"No face image was uploaded."
)
if
not
self
.
photo_id_image_url
:
raise
VerificationException
(
"No photo ID image was uploaded."
)
# 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
# attempt.
...
...
@@ -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
=
""
):
"""
...
...
@@ -268,15 +263,14 @@ class PhotoVerificationAttempt(StatusModel):
# If someone approves an outdated version of this, the first one wins
if
self
.
status
==
"approved"
:
return
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_service
=
service
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
)
lms/djangoapps/verify_student/ssencrypt.py
0 → 100644
View file @
8f572e53
"""
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
\xfe
r
\xaa\xf2\xab\xb4
M
\xe9\xe1
a
\x13\x1b
T5
\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
)
lms/djangoapps/verify_student/tests/test_ssencrypt.py
0 → 100644
View file @
8f572e53
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\xe1
a
\x13\x1b
T5
\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)
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment