Skip to content

Commit 3e74792

Browse files
0xPrashanthSecSaiprashanth Pulisettipre-commit-ci[bot]cclauss
authored
Feat/ciphers columnar (#13102)
* feat(ciphers): add scytale (skytale) transposition cipher with doctests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * chore(ciphers): satisfy ruff UP006/UP035 by using builtin generics * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * style(ciphers): fix import block formatting (isort I001) * feat(ciphers): add columnar transposition cipher with doctests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * refactor(ciphers): improve variable naming for clarity in columnar transposition cipher * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * updating DIRECTORY.md --------- Co-authored-by: Saiprashanth Pulisetti <itspulisetti@pm.me> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Christian Clauss <cclauss@me.com> Co-authored-by: cclauss <cclauss@users.noreply.github.com>
1 parent f363673 commit 3e74792

3 files changed

Lines changed: 232 additions & 0 deletions

File tree

DIRECTORY.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@
152152
* [Bifid](ciphers/bifid.py)
153153
* [Brute Force Caesar Cipher](ciphers/brute_force_caesar_cipher.py)
154154
* [Caesar Cipher](ciphers/caesar_cipher.py)
155+
* [Columnar Transposition](ciphers/columnar_transposition.py)
155156
* [Cryptomath Module](ciphers/cryptomath_module.py)
156157
* [Decrypt Caesar With Chi Squared](ciphers/decrypt_caesar_with_chi_squared.py)
157158
* [Deterministic Miller Rabin](ciphers/deterministic_miller_rabin.py)
@@ -181,6 +182,7 @@
181182
* [Shuffled Shift Cipher](ciphers/shuffled_shift_cipher.py)
182183
* [Simple Keyword Cypher](ciphers/simple_keyword_cypher.py)
183184
* [Simple Substitution Cipher](ciphers/simple_substitution_cipher.py)
185+
* [Skytale Cipher](ciphers/skytale_cipher.py)
184186
* [Transposition Cipher](ciphers/transposition_cipher.py)
185187
* [Transposition Cipher Encrypt Decrypt File](ciphers/transposition_cipher_encrypt_decrypt_file.py)
186188
* [Trifid Cipher](ciphers/trifid_cipher.py)

ciphers/columnar_transposition.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""Columnar Transposition cipher.
2+
3+
This classical cipher writes the plaintext in rows under a keyword and reads
4+
columns in the order of the alphabetical rank of the keyword letters.
5+
6+
Reference: https://en.wikipedia.org/wiki/Transposition_cipher#Columnar_transposition
7+
8+
We keep spaces and punctuation. Key must be alphabetic (case-insensitive).
9+
10+
>>> pt = "WE ARE DISCOVERED. FLEE AT ONCE"
11+
>>> ct = encrypt(pt, "ZEBRAS")
12+
>>> decrypt(ct, "ZEBRAS") == pt
13+
True
14+
15+
Edge cases:
16+
>>> encrypt("HELLO", "A")
17+
'HELLO'
18+
>>> decrypt("HELLO", "A")
19+
'HELLO'
20+
>>> encrypt("HELLO", "HELLO")
21+
'EHLLO'
22+
>>> decrypt("EHLLO", "HELLO")
23+
'HELLO'
24+
>>> encrypt("HELLO", "")
25+
Traceback (most recent call last):
26+
...
27+
ValueError: Key must be a non-empty alphabetic string
28+
"""
29+
30+
from __future__ import annotations
31+
32+
33+
def _normalize_key(key: str) -> str:
34+
k = "".join(ch for ch in key.upper() if ch.isalpha())
35+
if not k:
36+
raise ValueError("Key must be a non-empty alphabetic string")
37+
return k
38+
39+
40+
def _column_order(key: str) -> list[int]:
41+
# Stable sort by character then original index to handle duplicates
42+
indexed = list(enumerate(key))
43+
return [
44+
i
45+
for i, _ in sorted(
46+
indexed, key=lambda indexed_pair: (indexed_pair[1], indexed_pair[0])
47+
)
48+
]
49+
50+
51+
def encrypt(plaintext: str, key: str) -> str:
52+
"""Encrypt using columnar transposition.
53+
54+
:param plaintext: Input text (any characters)
55+
:param key: Alphabetic keyword
56+
:return: Ciphertext
57+
:raises ValueError: on invalid key
58+
"""
59+
k = _normalize_key(key)
60+
cols = len(k)
61+
if cols == 1:
62+
return plaintext
63+
64+
order = _column_order(k)
65+
66+
# Build ragged rows without padding
67+
rows = (len(plaintext) + cols - 1) // cols
68+
grid: list[str] = [plaintext[i * cols : (i + 1) * cols] for i in range(rows)]
69+
70+
# Read columns in sorted order, skipping missing cells
71+
out: list[str] = []
72+
for col in order:
73+
for r in range(rows):
74+
if col < len(grid[r]):
75+
out.append(grid[r][col])
76+
return "".join(out)
77+
78+
79+
def decrypt(ciphertext: str, key: str) -> str:
80+
"""Decrypt columnar transposition ciphertext.
81+
82+
:param ciphertext: Encrypted text
83+
:param key: Alphabetic keyword
84+
:return: Decrypted plaintext
85+
:raises ValueError: on invalid key
86+
"""
87+
k = _normalize_key(key)
88+
cols = len(k)
89+
if cols == 1:
90+
return ciphertext
91+
92+
order = _column_order(k)
93+
text_len = len(ciphertext)
94+
rows = (text_len + cols - 1) // cols
95+
r = text_len % cols
96+
97+
# Column lengths based on ragged last row (no padding during encryption)
98+
col_lengths: list[int] = []
99+
for c in range(cols):
100+
if r == 0:
101+
col_lengths.append(rows)
102+
else:
103+
col_lengths.append(rows if c < r else rows - 1)
104+
105+
# Slice ciphertext into columns following the sorted order
106+
columns: list[str] = [""] * cols
107+
idx = 0
108+
for col in order:
109+
ln = col_lengths[col]
110+
columns[col] = ciphertext[idx : idx + ln]
111+
idx += ln
112+
113+
# Rebuild plaintext row-wise
114+
out: list[str] = []
115+
pointers = [0] * cols
116+
for _ in range(rows * cols):
117+
c = len(out) % cols
118+
if pointers[c] < len(columns[c]):
119+
out.append(columns[c][pointers[c]])
120+
pointers[c] += 1
121+
return "".join(out)
122+
123+
124+
if __name__ == "__main__": # pragma: no cover
125+
import doctest
126+
127+
doctest.testmod()

ciphers/skytale_cipher.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""Scytale (Skytale) transposition cipher.
2+
3+
A classical transposition cipher used in ancient Greece. The sender wraps a
4+
strip of parchment around a rod (scytale) and writes the message along the rod.
5+
The recipient with a rod of the same diameter can read the message.
6+
7+
Reference: https://en.wikipedia.org/wiki/Scytale
8+
9+
Functions here keep characters as-is (including spaces). The key is a positive
10+
integer representing the circumference count (number of rows).
11+
12+
>>> encrypt("WE ARE DISCOVERED FLEE AT ONCE", 3)
13+
'WA SVEFETNERDCEDL C EIOR EAOE'
14+
>>> decrypt('WA SVEFETNERDCEDL C EIOR EAOE', 3)
15+
'WE ARE DISCOVERED FLEE AT ONCE'
16+
17+
Edge cases:
18+
>>> encrypt("HELLO", 1)
19+
'HELLO'
20+
>>> decrypt("HELLO", 1)
21+
'HELLO'
22+
>>> encrypt("HELLO", 5) # key equals length
23+
'HELLO'
24+
>>> decrypt("HELLO", 5)
25+
'HELLO'
26+
>>> encrypt("HELLO", 0)
27+
Traceback (most recent call last):
28+
...
29+
ValueError: Key must be a positive integer
30+
>>> decrypt("HELLO", -2)
31+
Traceback (most recent call last):
32+
...
33+
ValueError: Key must be a positive integer
34+
"""
35+
36+
from __future__ import annotations
37+
38+
39+
def encrypt(plaintext: str, key: int) -> str:
40+
"""Encrypt plaintext using Scytale transposition.
41+
42+
Write characters around a rod with `key` rows, then read off by rows.
43+
44+
:param plaintext: Input message to encrypt
45+
:param key: Positive integer number of rows
46+
:return: Ciphertext string
47+
:raises ValueError: if key <= 0
48+
"""
49+
if key <= 0:
50+
raise ValueError("Key must be a positive integer")
51+
if key == 1 or len(plaintext) <= key:
52+
return plaintext
53+
54+
# Read every key-th character starting from each row offset
55+
return "".join(plaintext[row::key] for row in range(key))
56+
57+
58+
def decrypt(ciphertext: str, key: int) -> str:
59+
"""Decrypt Scytale ciphertext.
60+
61+
Reconstruct rows by their lengths and interleave by columns.
62+
63+
:param ciphertext: Encrypted string
64+
:param key: Positive integer number of rows
65+
:return: Decrypted plaintext
66+
:raises ValueError: if key <= 0
67+
"""
68+
if key <= 0:
69+
raise ValueError("Key must be a positive integer")
70+
if key == 1 or len(ciphertext) <= key:
71+
return ciphertext
72+
73+
length = len(ciphertext)
74+
base = length // key
75+
extra = length % key
76+
77+
# Determine each row length
78+
row_lengths: list[int] = [base + (1 if r < extra else 0) for r in range(key)]
79+
80+
# Slice ciphertext into rows
81+
rows: list[str] = []
82+
idx = 0
83+
for r_len in row_lengths:
84+
rows.append(ciphertext[idx : idx + r_len])
85+
idx += r_len
86+
87+
# Pointers to current index in each row
88+
pointers = [0] * key
89+
90+
# Reconstruct by taking characters column-wise across rows
91+
result_chars: list[str] = []
92+
for i in range(length):
93+
r = i % key
94+
if pointers[r] < len(rows[r]):
95+
result_chars.append(rows[r][pointers[r]])
96+
pointers[r] += 1
97+
return "".join(result_chars)
98+
99+
100+
if __name__ == "__main__": # pragma: no cover
101+
import doctest
102+
103+
doctest.testmod()

0 commit comments

Comments
 (0)