From 25862159c4b946ec789ccc02fd8a075fb9b02fc7 Mon Sep 17 00:00:00 2001 From: dhondta Date: Sun, 22 Mar 2026 23:18:31 +0100 Subject: [PATCH 1/3] Added new codec: vigenere --- README.md | 1 + docs/pages/enc/crypto.md | 18 ++++++++++ src/codext/crypto/__init__.py | 1 + src/codext/crypto/vigenere.py | 65 +++++++++++++++++++++++++++++++++++ 4 files changed, 85 insertions(+) create mode 100755 src/codext/crypto/vigenere.py 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..21da6d9 100644 --- a/src/codext/crypto/__init__.py +++ b/src/codext/crypto/__init__.py @@ -9,5 +9,6 @@ from .rot import * from .scytale import * from .shift import * +from .vigenere import * from .xor import * 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) + From b6690d06c2b102e734d5bca9c98815bc8548361a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 23:09:10 +0000 Subject: [PATCH 2/3] Initial plan From 01d56c3e742c5ea8b6e732a9ba8d7a0ee4bed74c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Mar 2026 23:48:32 +0000 Subject: [PATCH 3/3] Add VIC cipher codec (straddling checkerboard + double columnar transposition) Co-authored-by: dhondta <9108102+dhondta@users.noreply.github.com> Agent-Logs-Url: https://github.com/dhondta/python-codext/sessions/8a98571c-48fc-4be7-a5d7-8472f4d900b4 --- README.md | 1 + docs/pages/enc/crypto.md | 20 ++++ src/codext/crypto/__init__.py | 1 + src/codext/crypto/vic.py | 187 ++++++++++++++++++++++++++++++++++ 4 files changed, 209 insertions(+) create mode 100644 src/codext/crypto/vic.py diff --git a/README.md b/README.md index b1d337f..5b92ef1 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] `vic-keyword-trans1[-trans2]`: aka VIC Cipher - [X] `vigenere`: aka Vigenere Cipher - [X] `xorN`: XOR with a single byte (*N* belongs to [1,255]) diff --git a/docs/pages/enc/crypto.md b/docs/pages/enc/crypto.md index 432ac3d..5e12c57 100644 --- a/docs/pages/enc/crypto.md +++ b/docs/pages/enc/crypto.md @@ -220,6 +220,26 @@ This is a dynamic encoding, that is, it holds the key. There is no default key, ----- +### VIC Cipher + +The VIC cipher combines a straddling checkerboard substitution (converting letters to a stream of digits) with a double columnar transposition applied to that digit stream. The checkerboard is built from a keyword-mixed alphabet; single-digit codes are assigned to the 8 letters filling the top row (columns 0–9 minus two blank columns), and two-digit codes to the remaining 18 letters across two lower rows. A keyword and two transposition keys are required. + +**Codec** | **Conversions** | **Aliases** | **Comment** +:---: | :---: | --- | --- +`vic` | text <-> VIC digit ciphertext | `vic-python-352`, `vic_secret_KEY1_KEY2` | requires checkerboard keyword and at least one transposition key + +```python +>>> import codext +>>> codext.encode("HELLO", "vic-python-352") +'42228285' +>>> codext.decode("42228285", "vic-python-352") +'HELLO' +>>> codext.encode("ATTACKATDAWN", "vic-python-352-461") +'8833231882605277' +``` + +----- + ### 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 21da6d9..76fb010 100644 --- a/src/codext/crypto/__init__.py +++ b/src/codext/crypto/__init__.py @@ -9,6 +9,7 @@ from .rot import * from .scytale import * from .shift import * +from .vic import * from .vigenere import * from .xor import * diff --git a/src/codext/crypto/vic.py b/src/codext/crypto/vic.py new file mode 100644 index 0000000..703062f --- /dev/null +++ b/src/codext/crypto/vic.py @@ -0,0 +1,187 @@ +# -*- coding: UTF-8 -*- +"""VIC Cipher Codec - vic content encoding. + +The VIC cipher is a complex manual cipher used by Soviet spies. It combines a +straddling checkerboard substitution (converting letters to a stream of digits) +with a double columnar transposition applied to that digit stream. + +The straddling checkerboard uses a keyword-mixed alphabet laid out in a 3-row +grid with two blank positions (default: columns 2 and 6) in the top row. +Letters in the top row get single-digit codes; letters in the two lower rows +get two-digit codes whose first digit is the blank-column header, making the +encoding self-synchronising. + +Parameters: + keyword : phrase to build the mixed alphabet for the checkerboard + trans1key : first columnar-transposition key (letters or digits) + trans2key : second columnar-transposition key (defaults to trans1key) + +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/vic-cipher +""" +from ..__common__ import * + + +__examples__ = { + 'enc-dec(vic-python-352)': ['HELLO', 'ATTACKATDAWN', 'TEST', ''], + 'enc-dec(vic-python-352-461)': ['HELLO', 'ATTACKATDAWN', 'TEST'], +} +__guess__ = [] + + +# Positions in the top row (0-9) that are left blank; their values become the +# row-header digits for the two lower rows of the checkerboard. +_BLANKS = (2, 6) + + +def _mixed_alpha(keyword): + """Return the 26-letter mixed alphabet derived from *keyword*.""" + seen, result = set(), [] + for c in keyword.upper(): + if c.isalpha() and c not in seen: + result.append(c) + seen.add(c) + for c in "ABCDEFGHIJKLMNOPQRSTUVWXYZ": + if c not in seen: + result.append(c) + return result + + +def _build_checkerboard(keyword): + """Build encode/decode lookup tables for the straddling checkerboard. + + Layout with blanks=(2,6): + col: 0 1 [2] 3 4 5 [6] 7 8 9 + row0: * * * * * * * * (8 single-digit codes) + row2: * * * * * * * * * * (10 two-digit codes 2x) + row6: * * * * * * * * (8 two-digit codes 6x) + """ + alpha = _mixed_alpha(keyword) + b0, b1 = _BLANKS + enc, dec, ai = {}, {}, 0 + # Top row – 8 positions + for col in range(10): + if col not in _BLANKS: + enc[alpha[ai]] = str(col) + dec[str(col)] = alpha[ai] + ai += 1 + # Second row – 10 positions, header digit = b0 + for col in range(10): + code = str(b0) + str(col) + enc[alpha[ai]] = code + dec[code] = alpha[ai] + ai += 1 + # Third row – remaining 8 positions, header digit = b1 + for col in range(8): # 26 total – 8 top-row – 10 second-row = 8 remaining + code = str(b1) + str(col) + enc[alpha[ai]] = code + dec[code] = alpha[ai] + ai += 1 + return enc, dec + + +def _col_order(key): + """Return column indices sorted by the character value of *key* (stable).""" + return [i for _, i in sorted(zip(key, range(len(key))))] + + +def _trans_encode(text, key): + """Columnar transposition: write row-by-row, read column-by-column in key order.""" + k, n = len(key), len(text) + if n == 0 or k == 0: + return text + order = _col_order(key) + result = [] + for col in order: + i = col + while i < n: + result.append(text[i]) + i += k + return ''.join(result) + + +def _trans_decode(text, key): + """Reverse columnar transposition.""" + k, n = len(key), len(text) + if n == 0 or k == 0: + return text + order = _col_order(key) + full_rows, remainder = n // k, n % k + cols = [None] * k + idx = 0 + for col in order: + col_len = full_rows + (1 if col < remainder else 0) + cols[col] = list(text[idx:idx + col_len]) + idx += col_len + result = [] + for row in range(full_rows + (1 if remainder else 0)): + for col in range(k): + if row < len(cols[col]): + result.append(cols[col][row]) + return ''.join(result) + + +def vic_encode(keyword, trans1, trans2): + enc_map, _ = _build_checkerboard(keyword) + # The framework converts pure-digit groups to int; convert back to str + t1 = str(trans1) + t2 = str(trans2) if trans2 else t1 + + def encode(text, errors="strict"): + _h = handle_error("vic", errors) + digits = [] + for pos, c in enumerate(ensure_str(text).upper()): + if c in enc_map: + digits.append(enc_map[c]) + else: + digits.append(_h(c, pos, ''.join(digits))) + digit_str = ''.join(d for d in digits if d) + step1 = _trans_encode(digit_str, t1) + step2 = _trans_encode(step1, t2) + return step2, len(step2) + + return encode + + +def vic_decode(keyword, trans1, trans2): + _, dec_map = _build_checkerboard(keyword) + b_set = {str(b) for b in _BLANKS} + # The framework converts pure-digit groups to int; convert back to str + t1 = str(trans1) + t2 = str(trans2) if trans2 else t1 + + def decode(text, errors="strict"): + _h = handle_error("vic", errors, decode=True) + t = ensure_str(text) + step1 = _trans_decode(t, t2) + digit_str = _trans_decode(step1, t1) + result, i = [], 0 + while i < len(digit_str): + d = digit_str[i] + if d in b_set: + code = digit_str[i:i + 2] + if code in dec_map: + result.append(dec_map[code]) + i += 2 + else: + result.append(_h(code, i, ''.join(result))) + i += 2 + elif d in dec_map: + result.append(dec_map[d]) + i += 1 + else: + result.append(_h(d, i, ''.join(result))) + i += 1 + r = ''.join(c for c in result if c) + return r, len(r) + + return decode + + +add("vic", vic_encode, vic_decode, + r"^vic[-_]([a-zA-Z]+)[-_]([a-zA-Z0-9]+)(?:[-_]([a-zA-Z0-9]+))?$")