diff --git a/README.md b/README.md index 45f488c..b1d337f 100644 --- a/README.md +++ b/README.md @@ -290,6 +290,7 @@ This category also contains `ascii85`, `adobe`, `[x]btoa`, `zeromq` with the `ba - [X] `rotN`: aka Caesar cipher (*N* belongs to [1,25]) - [X] `scytaleN`: encrypts using the number of letters on the rod (*N* belongs to [1,[) - [X] `shiftN`: shift ordinals (*N* belongs to [1,255]) +- [X] `vigenere`: aka Vigenere Cipher - [X] `xorN`: XOR with a single byte (*N* belongs to [1,255]) > :warning: Crypto functions are of course definitely **NOT** encoding functions ; they are implemented for leveraging the `.encode(...)` API from `codecs`. diff --git a/docs/pages/enc/crypto.md b/docs/pages/enc/crypto.md index 71d89e9..432ac3d 100644 --- a/docs/pages/enc/crypto.md +++ b/docs/pages/enc/crypto.md @@ -202,6 +202,24 @@ This is a dynamic encoding, that is, it can be called with an integer to define ----- +### Vigenere Cipher + +This is a dynamic encoding, that is, it holds the key. There is no default key, meaning that `vigenere` as the encoding scheme throws a `LookupError` indicating that the _key must be a non-empty alphabetic string_. + +**Codec** | **Conversions** | **Aliases** | **Comment** +:---: | :---: | --- | --- +`vigenere` | text <-> Vigenere ciphertext | `vigenere-abcdef`, `vigenere_MySuperSecret` | key only consists of characters, not digits + +```python +>>> codext.encode("This is a test !", "vigenere-abababa") +'Tiit it a tfsu !' +>>> codext.encode("This is a test !", "vigenere_MySuperSecret") +'Ffam xw r liuk !' +>>> codext.decode("Tiit it a tfsu !", "vigenere-abababa") +``` + +----- + ### XOR with 1 byte This is a dynamic encoding, that is, it can be called with an integer to define the ordinal of the byte to XOR with the input text. diff --git a/src/codext/crypto/__init__.py b/src/codext/crypto/__init__.py index 0854db2..00a2c58 100644 --- a/src/codext/crypto/__init__.py +++ b/src/codext/crypto/__init__.py @@ -4,10 +4,12 @@ from .bacon import * from .barbie import * from .citrix import * +from .playfair import * from .polybius import * from .railfence import * from .rot import * from .scytale import * from .shift import * +from .vigenere import * from .xor import * diff --git a/src/codext/crypto/playfair.py b/src/codext/crypto/playfair.py new file mode 100644 index 0000000..1194b37 --- /dev/null +++ b/src/codext/crypto/playfair.py @@ -0,0 +1,138 @@ +# -*- coding: UTF-8 -*- +"""Playfair Cipher Codec - playfair content encoding. + +The Playfair cipher is a symmetric encryption method using polygram substitution +with bigrams (pairs of letters), invented in 1854 by Charles Wheatstone, but +popularized by his friend Lord Playfair. + +This codec: +- en/decodes strings from str to str +- en/decodes strings from bytes to bytes +- decodes file content to str (read) +- encodes file content from str to bytes (write) + +Reference: https://www.dcode.fr/playfair-cipher +""" +from ..__common__ import * + + +__examples__ = { + # Classic example from Wikipedia (key "PLAYFAIR EXAMPLE"): + # the EE in "TREE" is split with an X filler during encoding, so decoding + # exposes the filler: "TREESTUMP" → encoded → decoded as "TREXESTUMP" + 'enc(playfair-playfairexample)': {'HIDETHEGOLDINTHETREESTUMP': 'BMODZBXDNABEKUDMUIXMMOUVIF'}, + 'dec(playfair-playfairexample)': {'BMODZBXDNABEKUDMUIXMMOUVIF': 'HIDETHEGOLDINTHETREXESTUMP'}, + 'enc-dec(playfair-keyword)': ['INSTRUMENT'], +} +__guess__ = ["playfair"] + + +# Standard 5×5 Playfair alphabet (I and J share the same cell) +_DEFAULT_ALPHABET = "ABCDEFGHIKLMNOPQRSTUVWXYZ" + + +def _build_grid(key=None): + """Build the 5×5 Playfair grid from an optional keyword.""" + seen, grid = set(), [] + if key: + for c in key.upper(): + if c == 'J': + c = 'I' + if c.isalpha() and c not in seen: + seen.add(c) + grid.append(c) + for c in _DEFAULT_ALPHABET: + if c not in seen: + seen.add(c) + grid.append(c) + pos = {grid[i]: (i // 5, i % 5) for i in range(25)} + return grid, pos + + +def _filler(c): + """Return the filler character for a given letter (X, or Q when the letter is X).""" + return 'Q' if c == 'X' else 'X' + + +def _make_bigrams(text): + """Convert plaintext to bigrams, inserting fillers for repeated-letter pairs.""" + chars = [] + for c in ensure_str(text).upper(): + if c == 'J': + chars.append('I') + elif c.isalpha(): + chars.append(c) + bigrams = [] + i = 0 + while i < len(chars): + a = chars[i] + if i + 1 < len(chars): + b = chars[i + 1] + if a == b: + bigrams.append((a, _filler(a))) + i += 1 + else: + bigrams.append((a, b)) + i += 2 + else: + bigrams.append((a, _filler(a))) + i += 1 + return bigrams + + +def _encode_bigram(grid, pos, a, b): + r_a, c_a = pos[a] + r_b, c_b = pos[b] + if r_a == r_b: + return grid[r_a * 5 + (c_a + 1) % 5], grid[r_b * 5 + (c_b + 1) % 5] + elif c_a == c_b: + return grid[((r_a + 1) % 5) * 5 + c_a], grid[((r_b + 1) % 5) * 5 + c_b] + else: + return grid[r_a * 5 + c_b], grid[r_b * 5 + c_a] + + +def _decode_bigram(grid, pos, a, b): + r_a, c_a = pos[a] + r_b, c_b = pos[b] + if r_a == r_b: + return grid[r_a * 5 + (c_a - 1) % 5], grid[r_b * 5 + (c_b - 1) % 5] + elif c_a == c_b: + return grid[((r_a - 1) % 5) * 5 + c_a], grid[((r_b - 1) % 5) * 5 + c_b] + else: + return grid[r_a * 5 + c_b], grid[r_b * 5 + c_a] + + +def playfair_encode(key=None): + grid, pos = _build_grid(key) + def encode(text, errors="strict"): + t = ensure_str(text) + result = [] + for a, b in _make_bigrams(t): + ea, eb = _encode_bigram(grid, pos, a, b) + result.extend([ea, eb]) + r = "".join(result) + return r, len(t) + return encode + + +def playfair_decode(key=None): + grid, pos = _build_grid(key) + def decode(text, errors="strict"): + t = ensure_str(text) + chars = [] + for c in t.upper(): + if c == 'J': + chars.append('I') + elif c.isalpha(): + chars.append(c) + result = [] + for i in range(0, len(chars) - 1, 2): + da, db = _decode_bigram(grid, pos, chars[i], chars[i + 1]) + result.extend([da, db]) + r = "".join(result) + return r, len(t) + return decode + + +add("playfair", playfair_encode, playfair_decode, r"^playfair(?:[-_]cipher)?(?:[-_]([a-zA-Z]+))?$", + printables_rate=1.) diff --git a/src/codext/crypto/vigenere.py b/src/codext/crypto/vigenere.py new file mode 100755 index 0000000..39ac4ce --- /dev/null +++ b/src/codext/crypto/vigenere.py @@ -0,0 +1,65 @@ +# -*- coding: UTF-8 -*- +"""Vigenere Cipher Codec - vigenere content encoding. + +This codec: +- en/decodes strings from str to str +- en/decodes strings from bytes to bytes +- decodes file content to str (read) +- encodes file content from str to bytes (write) +""" +from string import ascii_lowercase as LC, ascii_uppercase as UC + +from ..__common__ import * + + +__examples__ = { + 'enc(vigenere)': None, + 'enc(vigenere-lemon)': {'ATTACKATDAWN': 'LXFOPVEFRNHR'}, + 'enc(vigenere-key)': {'hello': 'rijvs'}, + 'enc(vigenère_key)': {'Hello World': 'Rijvs Uyvjn'}, + 'enc-dec(vigenere-secret)': ['hello world', 'ATTACK AT DAWN', 'Test 1234!'], +} +__guess__ = ["vigenere-key", "vigenere-secret", "vigenere-password"] + + +__char = lambda c, k, i, d=False: (LC if (b := c in LC) else UC)[(ord(c) - ord("Aa"[b]) + \ + [1, -1][d] * (ord(k[i % len(k)]) - ord('a'))) % 26] + + +def __check(key): + key = key.lower() + if not key or not key.isalpha(): + raise LookupError("Bad parameter for encoding 'vigenere': key must be a non-empty alphabetic string") + return key + + +def vigenere_encode(key): + def encode(text, errors="strict"): + result, i, k = [], 0, __check(key) + for c in ensure_str(text): + if c in LC or c in UC: + result.append(__char(c, k, i)) + i += 1 + else: + result.append(c) + r = "".join(result) + return r, len(r) + return encode + + +def vigenere_decode(key): + def decode(text, errors="strict"): + result, i, k = [], 0, __check(key) + for c in ensure_str(text): + if c in LC or c in UC: + result.append(__char(c, k, i, True)) + i += 1 + else: + result.append(c) + r = "".join(result) + return r, len(r) + return decode + + +add("vigenere", vigenere_encode, vigenere_decode, r"vigen[eè]re(?:[-_]cipher)?(?:[-_]([a-zA-Z]+))?$", penalty=.1) +