CSC110 / lectures / week09 / prep9.py
prep9.py
Raw
"""CSC110 Fall 2022 Prep 9: Programming Exercises

Instructions (READ THIS FIRST!)
===============================

This Python module contains several function headers and descriptions.
We have marked each place you need to fill in with the word "TODO".
As you complete your work in this file, delete each TODO comment.

You do not need to include doctests for this prep, though we strongly encourage you
to check your work carefully!

Copyright and Usage Information
===============================

This file is provided solely for the personal and private use of students
taking CSC110 at the University of Toronto St. George campus. All forms of
distribution of this code, whether as given or with any changes, are
expressly prohibited. For more information on copyright for CSC110 materials,
please consult our Course Syllabus.

This file is Copyright (c) 2022 David Liu, Mario Badr, and Tom Fairgrieve.
"""
from dataclasses import dataclass
import math

from python_ta.contracts import check_contracts


####################################################################################################
# Part 1
#
# Most websites where you can create an account now ask you for a "strong password". The definition
# of strong password varies. In Part 1, you will implement 1 definition of a strong password and
# get some practice with functions that mutate their input.
#
# For those curious, these definitions of a "strong password" don't actually seem to be based on
# evidence. See: https://nakedsecurity.sophos.com/2014/10/24/do-we-really-need-strong-passwords/
#
# If you are still coming up with your own passwords (or, worse, reusing the same password for more
# than one account), please look into getting a password manager.
####################################################################################################
@check_contracts
def is_strong_password(password: str) -> bool:
    """Return whether password is a strong password.

    A strong password has at least 6 characters, contains at least one lowercase letter, at least
    one uppercase letter, and at least one digit.

    >>> is_strong_password('I<3csc110')
    True
    >>> is_strong_password('ilovelamp')
    False
    """
    cond1 = len(password) >= 6 and len({i for i in password if str.islower(i)}) >= 1
    cond2 = len({i for i in password if str.isupper(i)}) >= 1
    cond3 = len({i for i in password if i in ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']}) >= 1
    return cond1 == cond2 == cond3


@check_contracts
def remove_weak_passwords(passwords: list[str]) -> list[str]:
    """Remove and return the weak passwords in the given list of passwords.

    A weak password is a password that is not strong.

    This function both mutates the given list (to remove the weak passwords)
    and returns a new list (the weak passwords that were removed).

    # Custom doctest.
    >>> remove_weak_passwords(['I<3csc110', 'ilovelamp'])
    ['ilovelamp']
    """
    # NOTE: This implementation has an error, and does not do what the docstring claims.
    # How would you explain the error? (Not to be handed in, but a good question for review!)
    # Answer: It is returning weak passwords correctly.
    # However, function is also asking to mutate given list so that weak passwords are being removed.
    # This function doesn't do that.
    # weak_passwords = [p for p in passwords if not is_strong_password(p)]
    # all_passwords = [p for p in passwords if is_strong_password(p)]
    # return weak_passwords

    # Next, in the space below, implement this function remove_weak_passwords correctly.
    # Warning: do NOT attempt to mutate the passwords list while looping over it
    # ("for password in passwords:"). This leads to unexpected errors in Python!
    # Instead, first compute the passwords to remove, and then remove each of them
    # from the list of passwords. (You can look up the "list.remove" method.)
    weak_passwords = [password for password in passwords if not is_strong_password(password)]
    for w_password in weak_passwords:
        list.remove(passwords, w_password)
    return weak_passwords


def test_mutation() -> None:
    """Test that remove_weak_passwords correctly mutates its list argument.

    Your test case should have at least one weak password in the input passwords.
    """
    test_input = ['I<3csc110', 'ilovelamp']
    remove_weak_passwords(test_input)
    assert test_input == ['I<3csc110']


def test_return() -> None:
    """Test that remove_weak_passwords correctly returns a list of the weak passwords.

    Your test case should have at least one weak password in the input passwords.
    """
    assert remove_weak_passwords(['I<3csc110', 'ilovelamp']) == ['ilovelamp']


####################################################################################################
# Part 2
#
# Ideally, the passwords we use when we create our accounts are not stored in plaintext. Otherwise,
# any hacker who finds their way into a company's server has just gained access to every password
# stored on that server, with no further hacking required. Here, you will implement some functions
# to encrypt passwords.
#
# Further reading (not required for this prep)
# ============================================
#
# Unfortunately, sometimes passwords are not stored securely.
# See: https://www.howtogeek.com/434930/why-are-companies-still-storing-passwords-in-plain-text/
#
# You may also find it interesting that encryption may not be the best method for storing passwords.
# Do a quick search for: hashing vs. encryption. Here are some relevant articles:
#   - https://cheapsslsecurity.com/blog/hashing-vs-encryption/
#   - https://cybernews.com/security/hashing-vs-encryption/
#   - https://www.maketecheasier.com/password-hashing-encryption/
####################################################################################################
@check_contracts
def divides(d: int, n: int) -> bool:
    """Return whether d divides n."""
    if d == 0:
        return n == 0
    else:
        return n % d == 0


@check_contracts
def is_prime(p: int) -> bool:
    """Return whether p is prime."""
    possible_divisors = range(2, math.floor(math.sqrt(p)) + 1)
    return p > 1 and all({not divides(d, p) for d in possible_divisors})


@check_contracts
def phi(n: int) -> int:
    """Return the number of positive integers between 1 and n inclusive that are coprime with n.

    You can use the math.gcd function imported from the math module.

    Preconditions:
        - n >= 1

    >>> phi(5)
    4
    >>> phi(15)
    8
    >>> phi(1)
    1
    """
    return len([a for a in range(0, n) if math.gcd(a + 1, n) == 1])


@check_contracts
@dataclass
class PublicKey:
    """A public key for the RSA cryptosystem.

    This is good review of data classes: this data class is another way of representing
    and RSA public key, rather than using tuples like we saw in class.

    DONE: translate these representation invariants into Python expressions under
          "Representation Invariants". Remember to use "self." to refer to instance attributes.
            1. n is greater than 1
            2. e is between 2 and phi(n) - 1, inclusive
            3. e is coprime to phi(n) (again, you can use math.gcd)
          Remember that you can check your representation invariants by creating instances
          of this data class in the Python console with attributes values that violate them;
          PythonTA should raise an AssertionError telling you which representation invariant was
          violated!

    Representation Invariants:
        - self.n > 1
        - min(phi(self.n) - 1, 2) <= self.e <= max(phi(self.n) - 1, 2)
        - math.gcd(self.e, phi(self.n)) == 1
    """
    n: int
    e: int


@check_contracts
@dataclass
class PrivateKey:
    """A private key for the RSA cryptosystem.

    DONE: translate these representation invariants into Python expressions under
          "Representation Invariants". Remember to use "self." to refer to instance attributes.
            1. p and q are both prime
            2. p and q are not equal
            3. d is between 2 and phi(p * q) - 1, inclusive
          Remember that you can check your representation invariants by creating instances
          of this data class in the Python console with attributes values that violate them;
          PythonTA should raise an AssertionError telling you which representation invariant was
          violated!

    Representation Invariants:
        - is_prime(self.p) == is_prime(self.q) == True
        - self.p != self.q
        - min(2, phi(self.p * self.q) - 1) <= self.d <= max(2, phi(self.p * self.q) - 1)
    """
    p: int
    q: int
    d: int


@check_contracts
def encrypt_password(public_key: PublicKey, password: str) -> str:
    """Return the password encrypted using public_key and the RSA cryptosystem.

    Your implementation should be very similar to the one from class, except now
    the public key is a data class rather than a tuple.
    """
    ciphertext = ''
    for letter in password:
        ciphertext = ciphertext + chr(pow(ord(letter), public_key.e, public_key.n))
    return ciphertext


@check_contracts
def decrypt_password(private_key: PrivateKey, encrypted: str) -> str:
    """Return decrypt the given encrypted password using private_key and the RSA cryptosystem.

    Your implementation should be very similar to the one from class, except now
    the public key is a data class rather than a tuple.
    """
    p, q, d = private_key.p, private_key.q, private_key.d
    n = p * q
    plaintext = ''
    for letter in encrypted:
        plaintext = plaintext + chr(pow(ord(letter), d, n))
    return plaintext


@check_contracts
def encrypt_passwords(public_key: PublicKey, passwords: list[str]) -> None:
    """Encrypt each password in passwords using the given public_key and the RSA cryptosystem.

    This function mutates passwords, and does not return anything.
    The encrypted passwords should appear in the same order as the
    corresponding original passwords.
    """
    for psw in range(0, len(passwords)):
        passwords[psw] = encrypt_password(public_key, passwords[psw])


if __name__ == '__main__':
    import doctest
    doctest.testmod(verbose=True)

    import pytest
    pytest.main(['prep9.py', '-v'])

    # When you are ready to check your work with python_ta, uncomment the following lines.
    # (In PyCharm, select the lines below and press Ctrl/Cmd + / to toggle comments.)
    import python_ta
    python_ta.check_all(config={
        'max-line-length': 120,
        'extra-imports': ['math']
    })