diff --git a/DIRECTORY.md b/DIRECTORY.md index 99646d31d860..9ecfff061732 100644 --- a/DIRECTORY.md +++ b/DIRECTORY.md @@ -152,6 +152,7 @@ * [Bifid](ciphers/bifid.py) * [Brute Force Caesar Cipher](ciphers/brute_force_caesar_cipher.py) * [Caesar Cipher](ciphers/caesar_cipher.py) + * [Columnar Transposition](ciphers/columnar_transposition.py) * [Cryptomath Module](ciphers/cryptomath_module.py) * [Decrypt Caesar With Chi Squared](ciphers/decrypt_caesar_with_chi_squared.py) * [Deterministic Miller Rabin](ciphers/deterministic_miller_rabin.py) @@ -181,6 +182,7 @@ * [Shuffled Shift Cipher](ciphers/shuffled_shift_cipher.py) * [Simple Keyword Cypher](ciphers/simple_keyword_cypher.py) * [Simple Substitution Cipher](ciphers/simple_substitution_cipher.py) + * [Skytale Cipher](ciphers/skytale_cipher.py) * [Transposition Cipher](ciphers/transposition_cipher.py) * [Transposition Cipher Encrypt Decrypt File](ciphers/transposition_cipher_encrypt_decrypt_file.py) * [Trifid Cipher](ciphers/trifid_cipher.py) diff --git a/ciphers/columnar_transposition.py b/ciphers/columnar_transposition.py new file mode 100644 index 000000000000..d697db6e1176 --- /dev/null +++ b/ciphers/columnar_transposition.py @@ -0,0 +1,127 @@ +"""Columnar Transposition cipher. + +This classical cipher writes the plaintext in rows under a keyword and reads +columns in the order of the alphabetical rank of the keyword letters. + +Reference: https://en.wikipedia.org/wiki/Transposition_cipher#Columnar_transposition + +We keep spaces and punctuation. Key must be alphabetic (case-insensitive). + +>>> pt = "WE ARE DISCOVERED. FLEE AT ONCE" +>>> ct = encrypt(pt, "ZEBRAS") +>>> decrypt(ct, "ZEBRAS") == pt +True + +Edge cases: +>>> encrypt("HELLO", "A") +'HELLO' +>>> decrypt("HELLO", "A") +'HELLO' +>>> encrypt("HELLO", "HELLO") +'EHLLO' +>>> decrypt("EHLLO", "HELLO") +'HELLO' +>>> encrypt("HELLO", "") +Traceback (most recent call last): + ... +ValueError: Key must be a non-empty alphabetic string +""" + +from __future__ import annotations + + +def _normalize_key(key: str) -> str: + k = "".join(ch for ch in key.upper() if ch.isalpha()) + if not k: + raise ValueError("Key must be a non-empty alphabetic string") + return k + + +def _column_order(key: str) -> list[int]: + # Stable sort by character then original index to handle duplicates + indexed = list(enumerate(key)) + return [ + i + for i, _ in sorted( + indexed, key=lambda indexed_pair: (indexed_pair[1], indexed_pair[0]) + ) + ] + + +def encrypt(plaintext: str, key: str) -> str: + """Encrypt using columnar transposition. + + :param plaintext: Input text (any characters) + :param key: Alphabetic keyword + :return: Ciphertext + :raises ValueError: on invalid key + """ + k = _normalize_key(key) + cols = len(k) + if cols == 1: + return plaintext + + order = _column_order(k) + + # Build ragged rows without padding + rows = (len(plaintext) + cols - 1) // cols + grid: list[str] = [plaintext[i * cols : (i + 1) * cols] for i in range(rows)] + + # Read columns in sorted order, skipping missing cells + out: list[str] = [] + for col in order: + for r in range(rows): + if col < len(grid[r]): + out.append(grid[r][col]) + return "".join(out) + + +def decrypt(ciphertext: str, key: str) -> str: + """Decrypt columnar transposition ciphertext. + + :param ciphertext: Encrypted text + :param key: Alphabetic keyword + :return: Decrypted plaintext + :raises ValueError: on invalid key + """ + k = _normalize_key(key) + cols = len(k) + if cols == 1: + return ciphertext + + order = _column_order(k) + text_len = len(ciphertext) + rows = (text_len + cols - 1) // cols + r = text_len % cols + + # Column lengths based on ragged last row (no padding during encryption) + col_lengths: list[int] = [] + for c in range(cols): + if r == 0: + col_lengths.append(rows) + else: + col_lengths.append(rows if c < r else rows - 1) + + # Slice ciphertext into columns following the sorted order + columns: list[str] = [""] * cols + idx = 0 + for col in order: + ln = col_lengths[col] + columns[col] = ciphertext[idx : idx + ln] + idx += ln + + # Rebuild plaintext row-wise + out: list[str] = [] + pointers = [0] * cols + for _ in range(rows * cols): + c = len(out) % cols + if pointers[c] < len(columns[c]): + out.append(columns[c][pointers[c]]) + pointers[c] += 1 + return "".join(out) + + +if __name__ == "__main__": # pragma: no cover + import doctest + + doctest.testmod() diff --git a/ciphers/skytale_cipher.py b/ciphers/skytale_cipher.py new file mode 100644 index 000000000000..11fe83e5c45c --- /dev/null +++ b/ciphers/skytale_cipher.py @@ -0,0 +1,103 @@ +"""Scytale (Skytale) transposition cipher. + +A classical transposition cipher used in ancient Greece. The sender wraps a +strip of parchment around a rod (scytale) and writes the message along the rod. +The recipient with a rod of the same diameter can read the message. + +Reference: https://en.wikipedia.org/wiki/Scytale + +Functions here keep characters as-is (including spaces). The key is a positive +integer representing the circumference count (number of rows). + +>>> encrypt("WE ARE DISCOVERED FLEE AT ONCE", 3) +'WA SVEFETNERDCEDL C EIOR EAOE' +>>> decrypt('WA SVEFETNERDCEDL C EIOR EAOE', 3) +'WE ARE DISCOVERED FLEE AT ONCE' + +Edge cases: +>>> encrypt("HELLO", 1) +'HELLO' +>>> decrypt("HELLO", 1) +'HELLO' +>>> encrypt("HELLO", 5) # key equals length +'HELLO' +>>> decrypt("HELLO", 5) +'HELLO' +>>> encrypt("HELLO", 0) +Traceback (most recent call last): + ... +ValueError: Key must be a positive integer +>>> decrypt("HELLO", -2) +Traceback (most recent call last): + ... +ValueError: Key must be a positive integer +""" + +from __future__ import annotations + + +def encrypt(plaintext: str, key: int) -> str: + """Encrypt plaintext using Scytale transposition. + + Write characters around a rod with `key` rows, then read off by rows. + + :param plaintext: Input message to encrypt + :param key: Positive integer number of rows + :return: Ciphertext string + :raises ValueError: if key <= 0 + """ + if key <= 0: + raise ValueError("Key must be a positive integer") + if key == 1 or len(plaintext) <= key: + return plaintext + + # Read every key-th character starting from each row offset + return "".join(plaintext[row::key] for row in range(key)) + + +def decrypt(ciphertext: str, key: int) -> str: + """Decrypt Scytale ciphertext. + + Reconstruct rows by their lengths and interleave by columns. + + :param ciphertext: Encrypted string + :param key: Positive integer number of rows + :return: Decrypted plaintext + :raises ValueError: if key <= 0 + """ + if key <= 0: + raise ValueError("Key must be a positive integer") + if key == 1 or len(ciphertext) <= key: + return ciphertext + + length = len(ciphertext) + base = length // key + extra = length % key + + # Determine each row length + row_lengths: list[int] = [base + (1 if r < extra else 0) for r in range(key)] + + # Slice ciphertext into rows + rows: list[str] = [] + idx = 0 + for r_len in row_lengths: + rows.append(ciphertext[idx : idx + r_len]) + idx += r_len + + # Pointers to current index in each row + pointers = [0] * key + + # Reconstruct by taking characters column-wise across rows + result_chars: list[str] = [] + for i in range(length): + r = i % key + if pointers[r] < len(rows[r]): + result_chars.append(rows[r][pointers[r]]) + pointers[r] += 1 + return "".join(result_chars) + + +if __name__ == "__main__": # pragma: no cover + import doctest + + doctest.testmod()