From 3757d179cbb4c6ae20deed9115aadc9cd71c7cef Mon Sep 17 00:00:00 2001 From: HusseinElguindi <34726859+HusseinElguindi@users.noreply.github.com> Date: Fri, 28 Jun 2024 18:11:28 -0400 Subject: [PATCH 1/2] implemented the Playfair cypher scheme --- python/solution.py | 200 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 200 insertions(+) diff --git a/python/solution.py b/python/solution.py index e69de29..2c410e6 100644 --- a/python/solution.py +++ b/python/solution.py @@ -0,0 +1,200 @@ +""" +Shopify Engineering Internship Playfair Cypher +Hussein Elguindi +June 28, 2024 +""" + +from string import ascii_uppercase +from abc import ABC, abstractmethod +from typing import Any, Final, Generator +from itertools import chain + + +class Cipher(ABC): + """ + An abstract base class defining the basis of cipher implementations + + Methods: + encrypt: accepts a plaintext string and returns the ciphertext + decrypt: accepts a ciphertext string and returns the plaintext + """ + @abstractmethod + def encrypt(self, plaintext: str) -> str: + pass + + @abstractmethod + def decrypt(self, ciphertext: str) -> str: + pass + + +class PlayfairCipher(Cipher): + """ + Implements the Cipher base class for the Playfair cypher scheme + + Methods: + encrypt: accepts plaintext and returns the Playfair ciphertext + decrypt: accepts Playfair ciphertext and returns the plaintext + """ + def __init__(self, cipher_key: str, dimension: int = 5): + """ + Initializes a new Playfair cipher object + + Attributes: + cipher_key: a string consisting of only uppercase ASCII letters + dimension: an optional integer defining the side lengths of the cipher key table + """ + if not cipher_key.isalpha() or not cipher_key.isupper(): + return ValueError("cipher key must consist of only uppercase ASCII letters") + + # An uncommon character (used to separate identical part digrams) + # Must not appear in decrypted plaintext + self.__none_char: Final[str] = "X" + # A pair of characters that should be treated identically + self.__identical_chars: Final[tuple[str, str]] = ("I", "J") + + self.__alphabet: Final[str] = ascii_uppercase + self.__cipher_key: Final[str] = cipher_key + self.__dimension: Final[int] = dimension + + self.__table: list[str] + self.__adjacency: dict[str, tuple[int, int]] + self.__build_table() + + def __build_table(self): + """ + Builds the cipher key table and adjacency map to be used for encryption/decryption + """ + # The key table + self.__table = [""] * (self.__dimension**2) + # Stores (row, col) indices of each char in the key table (to optimize searching during encryption/decryption) + self.__adjacency = {} + + # Populate the table with the cipher key, followed by the alphabet, without repeated characters + table_index = 0 + for char in chain(self.__cipher_key, self.__alphabet): + # The table is filled + if table_index >= len(self.__table): + break + + # The character was repeated + if char in self.__adjacency: + continue + + # Only one of the identical characters can appear + a, b = self.__identical_chars + if (char == a and b in self.__adjacency + or char == b and a in self.__adjacency + ): + continue + + self.__table[table_index] = char + # Convert index into (row, col) + self.__adjacency[char] = divmod(table_index, self.__dimension) + table_index += 1 + + def __digrams(self, text: str) -> Generator[tuple[str, str], Any, None]: + """ + A generator function yielding consecutive digrams from a string + + Args: + text: any string + """ + i = 0 + while i < len(text) - 1: + a, b = text[i], text[i + 1] + + # Separate pairs with identical characters using an uncommon character + if a == b: + yield (a, self.__none_char) + # Only increment by 1 to handle the other character in the pair + i += 1 + continue + + yield (a, b) + i += 2 + + # If the text length is odd, the digram is the last character and an uncommon character + if len(text) % 2 == 1: + yield (text[-1], self.__none_char) + + def __transform_digram(self, a: str, b: str, diff: int) -> str: + """ + Applies transformations on a digram as described by https://en.wikipedia.org/wiki/Playfair_cipher + The extent of a transformation can customized using the "diff" attribute + + Attributes: + a: the first character of the digram + b: the second character of the digram + diff: an integer specifying the amount to shift a character when applying a transformation, + for a standard Playfair cypher, this is often -1 for decrypt and 1 for encrypt + """ + a_row, a_col = self.__adjacency[a] + b_row, b_col = self.__adjacency[b] + + # Wraps an index around the key table dimensions + def wrap(i: int): + return i % self.__dimension + + # Case 1: the pair appears on the same row + if a_row == b_row: + offset = a_row * self.__dimension + i1, i2 = offset + wrap(a_col + diff), offset + wrap(b_col + diff) + + # Case 2: the pair appears on the same column + elif a_col == b_col: + i1, i2 = ( + a_row * self.__dimension + wrap(a_col + diff), + b_row * self.__dimension + wrap(b_col + diff), + ) + + # Case 3: the pair makes a rectangle on the table + else: + # Swap columns (i.e. get opposite corners on the rectangle) + a_col, b_col = b_col, a_col + i1, i2 = a_row * self.__dimension + a_col, b_row * self.__dimension + b_col + + return self.__table[i1] + self.__table[i2] + + def encrypt(self, plaintext: str) -> str: + # This is similar to the decrypt method except the "diff" parameter for transform_digram should be set to 1. That's all! + # Omitted out for the sake of brevity. + raise NotImplementedError("not yet implemented!") + + def decrypt(self, ciphertext: str) -> str: + """ + Decrypts ciphertext into plaintext according the Playfair cipher scheme + + Attributes: + ciphertext: a string consisting of ASCII letters + """ + if not ciphertext.isalpha(): + raise ValueError("ciphertext must consist of ASCII letters") + + ciphertext = ciphertext.upper() + + # Ensure that identical characters are treated identically + a, b = self.__identical_chars + if a in self.__adjacency: + ciphertext = ciphertext.replace(b, a) + else: + ciphertext = ciphertext.replace(a, b) + + # Transforming each digram of the ciphertext yields the plaintext + plaintext = "".join( + self.__transform_digram(a, b, diff=-1) + for a, b in self.__digrams(ciphertext) + ) + # The plaintext is guaranteed to consist of only uppercase ASCII letters + return plaintext.replace(self.__none_char, "") + + +def main(): + cipher_key = "SUPERSPY" + ciphertext = "IKEWENENXLNQLPZSLERUMRHEERYBOFNEINCHCV" + + cipher: Cipher = PlayfairCipher(cipher_key) + plaintext = cipher.decrypt(ciphertext) + print(plaintext) + +if __name__ == "__main__": + main() From e4fb3fb93a1f0c86c111c21cb912c8dd2eaf067f Mon Sep 17 00:00:00 2001 From: HusseinElguindi <34726859+HusseinElguindi@users.noreply.github.com> Date: Wed, 3 Jul 2024 10:58:43 -0400 Subject: [PATCH 2/2] remove type hints to support python 3.8 --- python/solution.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/solution.py b/python/solution.py index 2c410e6..4a4fc96 100644 --- a/python/solution.py +++ b/python/solution.py @@ -6,7 +6,7 @@ from string import ascii_uppercase from abc import ABC, abstractmethod -from typing import Any, Final, Generator +from typing import Final from itertools import chain @@ -92,7 +92,7 @@ def __build_table(self): self.__adjacency[char] = divmod(table_index, self.__dimension) table_index += 1 - def __digrams(self, text: str) -> Generator[tuple[str, str], Any, None]: + def __digrams(self, text: str): # -> Generator[tuple[str, str], Any, None]: """ A generator function yielding consecutive digrams from a string