Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions DIRECTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
127 changes: 127 additions & 0 deletions ciphers/columnar_transposition.py
Original file line number Diff line number Diff line change
@@ -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()
103 changes: 103 additions & 0 deletions ciphers/skytale_cipher.py
Original file line number Diff line number Diff line change
@@ -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()
Loading