Python Cryptography

The Python cryptography module is pretty straightforward to use. We will highlight here a few methods that are necessary for this assignment to save your time.

You can install the cryptography module using pip: python3.10 -m pip install cryptography. Make sure that it is installed to your Python3.10 and not your previous versions of Python, that is to call the pip with the python3.10 version (make sure its in $PATH, no other alias, etc). If you don’t know how to manage your modules for different versions of Python, please Google harder and try to understand how things are installed in your computer from your knowledge about the OS and the File System. We are way past this right now.

You can skim the sections below or jump straight ahead to read their official documentation about RSA (Asymmetric encryption) here and Fernet (Symmetric encryption) here.

Key Generation

In source/auth/generate_keys.py, we have provided you with instructions on how to generate 1024-bit RSA key pair. You can run the script as such:

cd source/auth
python3 generate_keys.py server

Two files will be produced in source/auth:

  1. server_private_key.pem: private key for server program (note that private key file also contain public key information as well)
  2. server_csr.pem: certificate signing request file, to be sent to our CA bot for signing purposes. The CA will return a .crt file containing the server’s public key.

Do not change the names of the files generated by generate_keys.py. Do not change the location of the files either. The two above must be placed in source/auth.

Reading Certificate

You can extract a public key from a .crt file. For instance, we give you cacsertificate.crt file containing our CA’s public key. You can use the CA public key later on to verify the authenticity of the certificate sent by the server.

First, you can open the .crt file as per normal as bytes data type,

from cryptography import x509
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.backends import default_backend

f = open("auth/cacsertificate.crt", "rb")
ca_cert_raw = f.read()

Then use the x509 method to load it:

ca_cert = x509.load_pem_x509_certificate(
    data=ca_cert_raw, backend=default_backend()
)

Afterwards, you can get a public key from the certificate:

ca_public_key = ca_cert.public_key()

Verify signature

You can use a public key to verify any signature data. For instance, suppose you have server_cert_raw byte data, containing the information about the signed server certificate by the CA. You can verify that this server_cert_raw is indeed issued by the CA using the verify method:

server_cert = x509.load_pem_x509_certificate(
    data=server_cert_raw, backend=default_backend()
)

ca_public_key.verify(
    signature=server_cert.signature, # signature bytes to  verify
    data=server_cert.tbs_certificate_bytes, # certificate data bytes that was signed by CA
    padding=padding.PKCS1v15(), # padding used by CA bot to sign the the server's csr
    algorithm=server_cert.signature_hash_algorithm,
)

An InvalidSignature exception will be raised if the signature fails to verify. Otherwise, the instructions will continue.

Then you can also extract server’s public key from server_cert after it passes the verification:

server_public_key = server_cert.public_key()

Check Certificate Validity

It is also important to check the validity of a certificate before proceeding with the FTP. You can do this easily by doing:

import datetime

assert server_cert.not_valid_before <= datetime.utcnow() <= server_cert.not_valid_after

Reading a .pem file

You can extract both the private key and public key from the .pem file generated by generate_keys.py. There’s no password used by default.

try:
    with open("auth/server_private_key.pem", mode="r", encoding="utf8") as key_file:
        private_key = serialization.load_pem_private_key(
            bytes(key_file.read(), encoding="utf8"), password=None
        )
    public_key = private_key.public_key()
except Exception as e:
    print(e)

# Use private_key or public_key for encryption or decryption from now onwards

Sign Message and Verify

We sign a message by encrypting it with our private_key. For instance,

message = bytes("hello world", encoding="utf-8")
signed_message = private_key.sign(
        message, # message in bytes format
        padding.PSS(
            mgf=padding.MGF1(hashes.SHA256()),
            salt_length=padding.PSS.MAX_LENGTH,
        ),
        hashes.SHA256(), # hashing algorithm used to hash the data before encryption
    )

We can then verify the message using the verify method that we have seen above with the corresponding public_key, otherwise will raise InvalidSignature Exception.

public_key.verify(
    signed_message,
    message,
    padding.PSS(
        mgf=padding.MGF1(hashes.SHA256()),
        salt_length=padding.PSS.MAX_LENGTH,
    ),
    hashes.SHA256(),
)
# will continue here if the verify above passes

Note that in the above example, SHA256 is used to hash the message first before encrypting it with private_key.

The padding used for the example above is PSS. It is a new signature padding scheme standard that should be used to pad signatures securely. You may read more about it here but it is out of our syllabus scope.

Also note that in the above ca_public_key.verify example, we used the old padding.PKCS1v15() because that’s just how our CA bot signed the .csr, however when our server signs the client’s message, it can use the more advanced PSS padding. Conversely, the client must also use PSS padding if the server used it to pad the digital signature.

Standard Encryption

You can encrypt a message with a public_key.

In the section above, you encrypt a message with a private_key, and we call this signing instead.

message = bytes("hello world", encoding="utf-8")
encrypted_message = public_key.encrypt(
        message,
        padding.OAEP(
            mgf=padding.MGF1(hashes.SHA256()),
            algorithm=hashes.SHA256(),
            label=None,
        ),
    )

Padding and Message Size

Note that in the example above, OAEP padding scheme is used. This is an RSA encryption padding scheme (as opposed to PSS, which is a signature padding scheme).

Note that there are two RSA signature schemes specified in PKCS1: RSASSA-PKCS1-v1_5 and RSASSA-PSS, and there are two RSA encryption schemes: RSAES-PKCS-v1_5 and RSAES-OAEP. The details are out of our syllabus.

The minimum length of OAEP padding is 66 bytes. A 1024 bit RSA keys can at most encrypt 128 bytes of message data chunk at a time. With 66 bytes of padding at minimum this leaves us with 62 bytes of message to encrypt at a time.

If you were to encrypt with PKCS1v15 instead (min 11 bytes of padding):

encrypted_message = public_key.encrypt(message, padding.PKCS1v15())

Then the maximum length of the message to encrypt at a time with 1024 bit RSA key is 117 bytes.

It is up to you to choose which padding implementation to use to encrypt the chunk of messages (file data) sent by client to the server. Just ensure that you set the chunk size accordingly.

Standard Decryption

You can decrypt a message with a private_key.

Again, you can also decrypt a signed message with a public_key, but the keyword used here is verify and not standard “decryption”.

decrypted_message = private_key.decrypt(
      encrypted_message, # in bytes
      padding.OAEP(      # padding should match whatever used during encryption
          mgf=padding.MGF1(hashes.SHA256()),
          algorithm=hashes.SHA256(),
          label=None,
      ),
  )

You can also decrypt a message with a public_key. In order to decrypt with a public key, this message has to be encrypted with a private key, and we call this a signature (instead of regular encrypted message). You have seen this in the previous section above.

Generating a Symmetric Key

You can generate a symmetric key as session key for a better file encryption performance. You can use the Fernet method to generate a secure symmetric key, instead of the usual AES/3DES. It is really simple to use:

from cryptography.fernet import Fernet
session_key_bytes = Fernet.generate_key() # generates 128-bit symmetric key as bytes
session_key = Fernet(session_key_bytes) # instantiate a Fernet instance with key

Using a Symmetric Key

Unlike RSA encryption, you can encrypt any byte datatype without separating them into chunks:

long_message = b""
with open("source/files/image.ppm", "rb") as f:
  long_message = f.read()
encrypted_long_message = session_key.encrypt(long_message)
decrypted_long_message = session_key.decrypt(encrypted_long_message)
assert decrypted_long_message == long_message

You may experiment with other symmetric key algorithms if you wish, but for this assignment, we expect you to use Fernet.

More details

More documentation details can always be found at their official documentation page. Utilise their search bar.