diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 91f67c9..9edfdf9 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -65,7 +65,7 @@ jobs: - uses: actions/checkout@v5 with: fetch-depth: 0 - ref: ${{ github.head_ref || github.ref_name }} + ref: ${{ (github.event_name == 'pull_request' || github.event_name == 'pull_request_target') && github.event.pull_request.head.sha || github.sha }} - name: Set up Python ${{ env.python_version }} uses: actions/setup-python@v6 with: diff --git a/README.md b/README.md index 8100ff9..eae7445 100644 --- a/README.md +++ b/README.md @@ -336,12 +336,15 @@ This category also contains `ascii85`, `adobe`, `[x]btoa`, `zeromq` with the `ba - [X] `barbie-N`: aka Barbie Typewriter (*N* belongs to [1, 4]) - [X] `beaufort`: aka Beaufort Cipher (variant of Vigenere Cipher) - [X] `citrix`: aka Citrix CTX1 password encoding +- [X] `playfair`: aka Playfair Cipher +- [X] `phillips`: aka Phillips Cipher (polyalphabetic block cipher with 8 key squares) - [X] `polybius`: aka Polybius Square Cipher - [X] `railfence`: aka Rail Fence Cipher - [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] `trithemius`: aka Trithemius Cipher (variant of Vigenere Cipher) +- [X] `vic`: aka VIC Cipher - [X] `vigenere`: aka Vigenere Cipher - [X] `xorN`: XOR with a single byte (*N* belongs to [1,255]) diff --git a/docs/hakin9-codext.pdf b/docs/hakin9-codext.pdf new file mode 100644 index 0000000..a610585 Binary files /dev/null and b/docs/hakin9-codext.pdf differ diff --git a/docs/pages/enc/crypto.md b/docs/pages/enc/crypto.md index c065569..66a42fe 100644 --- a/docs/pages/enc/crypto.md +++ b/docs/pages/enc/crypto.md @@ -162,6 +162,25 @@ This implements the Citrix CTX1 password encoding algorithm. ----- +### Phillips Cipher + +This implements Phillips cipher, a polyalphabetic code using 8 grids generated with one keyword. + +**Codec** | **Conversions** | **Aliases** | **Comment** +:---: | :---: | --- | --- +`phillips` | text <-> phillips ciphertext | `phillips-key`, `phillips_password`, ... | + +```python +>>> codext.encode("this is a test", "phillips_mysuperkey") +'ZCNM NM E XKMVZ' +>>> codext.encode("Another Test String", "phillips-PaSsWoRd") +'SMEZKBE LBON OLEHQHV' +>>> codext.decode("SMEZKBE LBON OLEHQHV", "phillips-password") +'ANOTHER TEST STRINGX' +``` + +----- + ### Polybius Square Cipher This implements the well-known Polybius Square cipher, using the square with the alphabet in normal order as the default. It can be used dynamically with a custom alphabet. @@ -238,6 +257,25 @@ This is a dynamic encoding, that is, it can be called with an integer to define ----- +### Playfair Cipher + +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 Lord Playfair. + +**Codec** | **Conversions** | **Aliases** | **Comment** +:---: | :---: | --- | --- +`playfair` | text <-> Playfair ciphertext | `playfair`, `playfair-playfairexample`, `playfair-keyword` | Dynamic key parameter ; letters only ; `J` is normalized to `I` + +```python +>>> codext.encode("HIDETHEGOLDINTHETREESTUMP", "playfair-playfairexample") +'BMODZBXDNABEKUDMUIXMMOUVIF' +>>> codext.decode("BMODZBXDNABEKUDMUIXMMOUVIF", "playfair-playfairexample") +'HIDETHEGOLDINTHETREXESTUMP' +>>> codext.decode(codext.encode("INSTRUMENT", "playfair-keyword"), "playfair-keyword") +'INSTRUMENT' +``` + +----- + ### Trithemius Cipher This is a variant of the [Vigenere Cipher](#vigenere-cipher) with key `"ABCDEFGHIJKLMNOPQRSTUVWXYZ"`. @@ -255,6 +293,30 @@ This is a variant of the [Vigenere Cipher](#vigenere-cipher) with key `"ABCDEFGH ----- +### VIC Cipher + +The VIC cipher combines a straddling checkerboard substitution (converting letters to a stream of digits) and an over-encryption based on a modulo-10 sum with eventually a conversion from digits to text. + +**Codec** | **Conversions** | **Aliases** | **Comment** +:---: | :---: | --- | --- +`vic` | text <-> VIC digit/text ciphertext | `vic-keyword`, `vic-key-12`, `vic-^-26-0248`, `vic-*-73-534T` | optional checkerboard keyword or alphabet marker, blank positions (exactly 2 distinct digits), over-encryption key (digits), "`T`" marker for text conversion + +Alphabet markers: +- `*`: uses "`ABC[...]XYZ./`" (uppercase letters + "`./`") +- `^`: uses "`./ZYX[...]CBA`" ("`./`" + reversed uppercase letters) + +```python +>>> import codext +>>> codext.encode("VICTOR", "vic-^-26-0248T") +'VVXYWYY.XYJ' +>>> codext.decode("VVXYWYY.XYJ", "vic-^-26-0248T") +'VICTOR2' # trailing superfluous letter because of text conversion +>>> codext.encode("LONGTESTSTRING", "vic-^-85-72564") +'5031237844273727064454072378' +``` + +----- + ### 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_. @@ -292,4 +354,3 @@ This is a dynamic encoding, that is, it can be called with an integer to define >>> codext.encode("~bcy*cy*k*~oy~", "xor-10") 'this is a test' ``` - diff --git a/src/codext/VERSION.txt b/src/codext/VERSION.txt index d00a804..c49bd49 100644 --- a/src/codext/VERSION.txt +++ b/src/codext/VERSION.txt @@ -1 +1 @@ -1.16.1 +1.16.5 diff --git a/src/codext/base/_base.py b/src/codext/base/_base.py index f41df0b..196a07a 100644 --- a/src/codext/base/_base.py +++ b/src/codext/base/_base.py @@ -125,11 +125,17 @@ def base_encode(input, charset, errors="strict", exc=BaseEncodeError): if i > SIZE_LIMIT: raise InputSizeLimitError("Input exceeded size limit") return i * charset[0] - if n == 10: - return str(i) if charset == digits else "".join(charset[int(x)] for x in str(i)) + # keep this fast-path only for a non-standard 10-character charset ; the digits + # charset uses the generic loop below (leading zeros, bignums, no str/int round-trip) + if n == 10 and charset != digits: + return "".join(charset[int(x)] for x in str(i)) while i > 0: i, c = divmod(i, n) r = charset[c] + r + # preserve leading zero bytes: big-integer bases such as Base58 map each + # leading null byte of the input to a leading charset[0] character + if not isinstance(input, int): + r = charset[0] * (len(input) - len(input.lstrip("\x00"))) + r return r @@ -144,14 +150,15 @@ def base_decode(input, charset, errors="strict", exc=BaseDecodeError): i, n, dec = 0, len(charset), lambda n: base_encode(n, [chr(x) for x in range(256)], errors, exc) if n == 1: return i2s(len(input)) - if n == 10: - return i2s(int(input)) if charset == digits else "".join(str(charset.index(c)) for c in input) + if n == 10 and charset != digits: + return "".join(str(charset.index(c)) for c in input) for k, c in enumerate(input): try: i = i * n + charset.index(c) except ValueError: handle_error("base", errors, exc, decode=True)(c, k, dec(i), "base%d" % n) - return dec(i) + # restore the leading zero bytes encoded as leading charset[0] characters + return chr(0) * (len(input) - len(input.lstrip(charset[0]))) + dec(i) # base codec factory functions diff --git a/src/codext/base/base45.py b/src/codext/base/base45.py index 272c3e9..1590460 100644 --- a/src/codext/base/base45.py +++ b/src/codext/base/base45.py @@ -34,7 +34,10 @@ def base45_encode(mode): b45 = _get_charset(B45, mode) def encode(text, errors="strict"): t, s = b(text), "" - for i in range(0, len(text), 2): + # iterate over the byte sequence (t), not len(text): when the input + # holds non-ASCII characters, b(text) is longer than text and using + # len(text) silently drops the trailing bytes + for i in range(0, len(t), 2): n = 256 * __ord(t[i]) try: n += __ord(t[i+1]) @@ -54,7 +57,7 @@ def base45_decode(mode): def decode(text, errors="strict"): t, s = b(text), "" ehandler = handle_error("base45", errors, decode=True) - for i in range(0, len(text), 3): + for i in range(0, len(t), 3): try: n = b45[__chr(t[i])] except KeyError: diff --git a/src/codext/checksums/crc.py b/src/codext/checksums/crc.py index dfea7ee..f2e9db2 100644 --- a/src/codext/checksums/crc.py +++ b/src/codext/checksums/crc.py @@ -13,6 +13,7 @@ CRC = { '': { + '': (0x1021, 0xc6c6, True, True, 0, 0xbf05), 'a': (0x1021, 0xc6c6, True, True, 0, 0xbf05), }, 8: { @@ -78,6 +79,7 @@ 'mpt1327': (0x6815, 0, False, False, 1, 0x2566), }, 16: { + '': (0x8005, 0, True, True, 0, 0xbb3d), 'acorn': (0x1021, 0, False, False, 0, 0x31c3), 'arc': (0x8005, 0, True, True, 0, 0xbb3d), 'atom': (0x002d, 0, True, True, 0, 0x4287), diff --git a/src/codext/crypto/__init__.py b/src/codext/crypto/__init__.py index 21da6d9..d9d85b8 100644 --- a/src/codext/crypto/__init__.py +++ b/src/codext/crypto/__init__.py @@ -4,11 +4,13 @@ from .bacon import * from .barbie import * from .citrix import * +from .playfair import * +from .phillips import * from .polybius import * from .railfence import * from .rot import * from .scytale import * from .shift import * +from .vic import * from .vigenere import * from .xor import * - diff --git a/src/codext/crypto/phillips.py b/src/codext/crypto/phillips.py new file mode 100644 index 0000000..c52ae59 --- /dev/null +++ b/src/codext/crypto/phillips.py @@ -0,0 +1,108 @@ +# -*- coding: UTF-8 -*- +"""Phillips Cipher Codec - phillips content encoding. + +The Phillips cipher is a polyalphabetic substitution cipher using 8 key squares. The first square is a 5×5 grid built + from a keyword (I and J share one cell). Seven additional squares are derived by sequentially swapping adjacent rows + in a descending bubble pattern. Plaintext is divided into blocks of T letters (default 5); each letter is individually + enciphered by shifting its grid position right by DH columns and down by DV rows (both default to 1), wrapping + around with toroidal topology. J is treated as I. + +Parameters: + key -- keyword used to seed the initial 5×5 grid (required) + block_size -- number of letters per grid-cycle block, 1–25 (default 5) + dh -- horizontal (column) shift for encryption, 1–4 (default 1) + dv -- vertical (row) shift for encryption, 1–4 (default 1) + +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/phillips-cipher +""" +from ..__common__ import * + + +__examples__ = { + 'dec(phillips)': None, + 'enc(phillips)': None, + 'enc(phillips-key)': {'ATTACK1': None}, + 'enc(phillips-key)': {'ATTACK': "HUUHLL", 'TESTME': "UFZUSM", 'ABCDEF': "HCLMFA"}, + 'enc(phillips-key-5-1-2)': {'This is a Test String': "KPVBVHTCRHCHCGQBT"}, + 'dec(phillips-key-5-1-2)': {'KPVBVHTCRHCHCGQBT': "THISISATESTSTRING"}, + 'enc-dec(phillips-key)': ["ATTACK", "TESTME", "ABCDEF"], + 'enc-dec(phillips-secret)': ["HELLOWORLD", "ATTACKATDAWN"], + 'enc-dec(phillips-key-2)': ["ATTACK", "TESTME"], + 'enc-dec(phillips-key-5-2)': ["ATTACK"], + 'enc-dec(phillips-key-5-1-2)': ["ATTACK"], +} +__guess__ = ["phillips-key", "phillips-secret", "phillips-password"] + + +_ALPHABET = "ABCDEFGHIKLMNOPQRSTUVWXYZ" + + +def __make_grids(key): + """Return all 8 grids built by a descending bubble-swap row permutation.""" + seen, letters = set(), [] + for c in key.upper().replace("J", "I") + _ALPHABET: + if c in set(_ALPHABET) and c not in seen: + letters.append(c) + seen.add(c) + grid = [letters[i * 5:(i + 1) * 5] for i in range(5)] + grids = [grid] + for k in range(7): + r = k % 4 + grid = [row[:] for row in grid] + grid[r], grid[r + 1] = grid[r + 1], grid[r] + grids.append(grid) + return grids + + +def __set_params(key, block_size, dh, dv): + return (key or "").strip(), \ + int(block_size) if block_size else 5, \ + int(dh) if dh else 1, \ + int(dv) if dv else 1, \ + __make_grids(key) if key and key.isalpha() else None + + +def __shift_text(text, grids, block_size, dh, dv, errors, decode=False): + pos_maps = [{ch: (r, c) for r, row in enumerate(grid) for c, ch in enumerate(row)} for grid in grids] + r, i, t = "", 0, ensure_str(text).upper().replace("J", "I").replace(" ", "") + _h = handle_error("phillips", errors, decode=decode) + for pos, c in enumerate(t): + if c not in set(_ALPHABET): + r += _h(c, pos, r) + else: + grid_idx = (i // block_size) % 8 + grid = grids[grid_idx] + s, col = pos_maps[grid_idx][c] + r += grid[(s + dv) % 5][(col + dh) % 5] + i += 1 + return r, len(r) + + +def phillips_encode(key, block_size=None, dh=None, dv=None): + _key, block_size, dh, dv, grids = __set_params(key, block_size, dh, dv) + def encode(text, errors="strict"): + if _key == "": + raise LookupError("Bad parameter for encoding 'phillips': key cannot be empty") + return __shift_text(text, grids, block_size, dh, dv, errors) + return encode + + +def phillips_decode(key, block_size=None, dh=None, dv=None): + _key, block_size, dh, dv, grids = __set_params(key, block_size, dh, dv) + def decode(text, errors="strict"): + if _key == "": + raise LookupError("Bad parameter for encoding 'phillips': key cannot be empty") + return __shift_text(text, grids, block_size, -dh, -dv, errors, True) + return decode + + +add("phillips", phillips_encode, phillips_decode, + r"^phillips(?:[-_]cipher)?(?:[-_]([a-zA-Z]+))?(?:[-_]([1-9]|1[0-9]|2[0-5]))?(?:[-_]([1-4]))?(?:[-_]([1-4]))?$", + printables_rate=1., penalty=.1) + diff --git a/src/codext/crypto/playfair.py b/src/codext/crypto/playfair.py new file mode 100644 index 0000000..040ad00 --- /dev/null +++ b/src/codext/crypto/playfair.py @@ -0,0 +1,143 @@ +# -*- 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 string import ascii_uppercase as UC, digits as DG + +from ..__common__ import * + + +__examples__ = { + 'dec(playfair_cipher-testkey-4)': None, + 'dec(playfair)': {'SIHTHTDQCUUTUSHOHW': "THISISATESTSTRINGX", '1a': None, 'a1b': None}, + 'dec(playfair-playfairexample)': {'BMODZBXDNABEKUDMUIXMMOUVIF': 'HIDETHEGOLDINTHETREXESTUMP'}, + 'enc(playfair-*-5-XQ-IJ)': {'UDTUTUTGMH': "TESTSTRJNG"}, + 'enc(playfair_cipher-*-5-XX)': None, + 'enc(playfair_cipher-*-5-X-JJ)': None, + 'enc(playfair)': {'String!': None, 'This is a test String': "SIHTHTDQCUUTUSHOHW"}, + 'enc(playfair-*-5-XQ-IJ)': {'TESTSTRING': "UDTUTUTGMH"}, + 'enc(playfair-playfairexample)': {'HIDETHEGOLDINTHETREESTUMP': 'BMODZBXDNABEKUDMUIXMMOUVIF'}, + 'enc-dec(playfair-keyword)': ["TEST"], + 'enc-dec(playfair-*-5-X-IJ)': ["TEST", "TESTSENTENCE"], +} +__guess__ = ["playfair"] + + +# Standard 5×5 Playfair alphabet (I and J share the same cell) +_DEFAULT_ALPHABET = { + 5: UC.replace("J", ""), + 6: UC + DG, +} + + +def __build_grid(key=None, grid_size=5, replace_char=('J', 'I')): + """Build the Playfair grid from an optional keyword. """ + seen, grid = set(), [] + for c in key.upper(): + if c == replace_char[0]: + c = replace_char[1] + if c.isalpha() and c not in seen: + seen.add(c) + grid.append(c) + for c in _DEFAULT_ALPHABET[grid_size]: + if c not in seen: + seen.add(c) + grid.append(c) + return grid, {grid[i]: (i // grid_size, i % grid_size) for i in range(grid_size ** 2)} + + +def __set_params(key, grid_size, fill_chars, replace_char): + if len(fc := str(fill_chars or "XQ").upper()) == 2 and fc[0] == fc[1]: + raise LookupError("Bad parameter for encoding 'playfair': filling char must be a single letter or a pair of " + "distinct letters") + if len(rc := str(replace_char or "JI").upper()) == 2 and rc[0] == rc[1]: + raise LookupError("Bad parameter for encoding 'playfair': replaced char must be a pair of distinct letters") + gs = int(grid_size or 5) + a = _DEFAULT_ALPHABET[gs] + if len(fc) == 1: + fc += a[(a.index(fc[0])+1) % len(a)] + return (a if key == "*" else a[::-1] if key == "^" else key).upper(), gs, tuple(fc), tuple(rc) + + +def playfair_encode(key=None, grid_size=None, fill_chars=None, replace_char=None): + key, gsize, fchars, rchar = __set_params(key, grid_size, fill_chars, replace_char) + grid, pos = __build_grid(key, gsize, rchar) + def encode(text, errors="strict"): + _filler = lambda c: fchars[1] if c == fchars[0] else fchars[0] + chars = [rchar[1] if c == rchar[0] else c for c in ensure_str(text).upper().replace(" ", "")] + result, i, _h = [], 0, handle_error("playfair", errors) + while i < len(chars): + a = chars[i] + if a not in grid: + result.append(_h(a, i)) + i += 1 + continue + if i + 1 < len(chars): + b = chars[i+1] + if a == b: + b = _filler(a) + else: + i += 1 + else: + b = _filler(a) + r_a, c_a = pos[a] + r_b, c_b = pos[b] + if r_a == r_b: + ea, eb = grid[r_a * 5 + (c_a + 1) % 5], grid[r_b * 5 + (c_b + 1) % 5] + elif c_a == c_b: + ea, eb = grid[((r_a + 1) % 5) * 5 + c_a], grid[((r_b + 1) % 5) * 5 + c_b] + else: + ea, eb = grid[r_a * 5 + c_b], grid[r_b * 5 + c_a] + result.extend([ea, eb]) + i += 1 + return (r := "".join(result)), len(r) + return encode + + +def playfair_decode(key=None, grid_size=None, fill_chars=None, replace_char=None): + key, gsize, _, rchar = __set_params(key, grid_size, fill_chars, replace_char) + grid, pos = __build_grid(key, gsize, rchar) + def decode(text, errors="strict"): + chars = [rchar[1] if c == rchar[0] else c for c in ensure_str(text)] + result, i, _h = [], 0, handle_error("playfair", errors, decode=True) + while i < len(chars): + try: + r_a, c_a = pos[chars[i]] + except KeyError: + result.append(_h(chars[i], i, "".join(result))) + i += 1 + continue + try: + r_b, c_b = pos[chars[i+1]] + except KeyError: + result.append(chars[i]) + result.append(_h(chars[i+1], i+1, "".join(result))) + i += 2 + continue + if r_a == r_b: + da, db = grid[r_a * 5 + (c_a - 1) % 5], grid[r_b * 5 + (c_b - 1) % 5] + elif c_a == c_b: + da, db = grid[((r_a - 1) % 5) * 5 + c_a], grid[((r_b - 1) % 5) * 5 + c_b] + else: + da, db = grid[r_a * 5 + c_b], grid[r_b * 5 + c_a] + result.extend([da, db]) + i += 2 + return (r := "".join(result)), len(r) + return decode + + +add("playfair", playfair_encode, playfair_decode, + r"^playfair(?:[-_]cipher)?(?:[-_]([\^*]|[a-zA-Z]+))?(?:[-_]([56]))?(?:[-_]([A-Z]{1,2}))?(?:[-_]([A-Z]{2}))?$", + printables_rate=1., penalty=.1) + diff --git a/src/codext/crypto/polybius.py b/src/codext/crypto/polybius.py index 73fae76..81ff0f1 100755 --- a/src/codext/crypto/polybius.py +++ b/src/codext/crypto/polybius.py @@ -11,6 +11,8 @@ __examples__ = { + 'enc(polybius-ABCDEFGHIKLMNOPQRSTUVZZZZ)': None, + 'dec(polybius)': {'4423244 24': None}, 'enc(polybius|polybius-square|polybius_square)': {'this is a test': "44232443 2443 11 44154344"}, 'enc(polybius-ABCDEFGHIKLMNOPQRSTUVWXYZ)': {'this is a test': "44232443 2443 11 44154344"}, 'dec(polybius)': {'44232443 2443 11 44154344': "THIS IS A TEST"}, @@ -59,15 +61,12 @@ def decode(text, errors="strict"): _h = handle_error("polybius", errors, decode=True) r, t, i = "", ensure_str(text), 0 while i < len(t): - if t[i] == " ": - r += " " - i += 1 - elif i + 1 < len(t): + if t[i:i+2].isdigit(): r += decmap.get(t[i:i+2]) or _h(t[i:i+2], i, r) - i += 2 - else: - r += _h(t[i], i, r) i += 1 + else: + r += " " if t[i] == " " else _h(t[i], i, r) + i += 1 return r, len(t) return decode diff --git a/src/codext/crypto/vic.py b/src/codext/crypto/vic.py new file mode 100755 index 0000000..233701d --- /dev/null +++ b/src/codext/crypto/vic.py @@ -0,0 +1,142 @@ +# -*- coding: UTF-8 -*- +"""Vic Cipher Codec - vic 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 itertools import cycle +from string import ascii_uppercase as UC + +from ..__common__ import * + + +__examples__ = { + 'dec(vic-test-33)': None, + 'dec(vic-*-12-1234T': {'BBXMBDMBCBBHEMC': "THISISATEST"}, + 'dec(vic-^-26-0248T)': {'VVXYWYY.XYJ': "VICTOR2"}, # '2' because of superfluous extra letter from substitution + 'enc(vic-test-12-ABC)': None, + 'enc(vic-*-12-1234T': {'This is a Test': "BBXMBDMBCBBHEMC"}, + 'enc(vic-^-26-0248)': {'VICTOR': "88547440546"}, + 'enc(vic-^-26-0248T)': {'VICTOR': "VVXYWYY.XYJ"}, + 'enc-dec(vic-^-26)': ["TEST", "LONGTESTSTRING", "VICTOR"], + 'enc-dec(vic-^-85-72564)': ["TEST", "LONGTESTSTRING", "VICTOR"], + 'enc-dec(vic-^-85-54321T)': ["TEST", "LONGTESTSTRING", "VICTOR"], +} +__guess__ = ["vic"] + + +def __build_alphabet(key, alphabet=UC, reverse=False): + """Return the 28-letter alphabet derived from the input key.""" + seen, result = set(), "" + for c in (key or "").upper(): + if c.isalpha() and c not in seen: + result += c + seen.add(c) + for c in (alphabet[::-1] if reverse else alphabet): + if c not in seen: + result += c + return "./" + result if reverse else result + "./" + + +def __build_checkerboard(alphabet, blank1=1, blank2=2): + """Build encode/decode lookup tables for the straddling checkerboard. + + Layout with digit1=2 and digit2=6: + 0 1 [2] 3 4 5 [6] 7 8 9 + 0: * * * * * * * * + 2: * * * * * * * * * * + 6: * * * * * * * * * * + """ + if blank1 == blank2: + raise LookupError(f"Bad parameter for encoding 'vic': blank1 and blank2 cannot be identical") + enc, dec, i = {}, {}, 0 + # top row + for col in range(10): + if col not in (blank1, blank2): + enc[alphabet[i]] = str(col) + i += 1 + # second row ; header digit is 'blank1' + for col in range(10): + enc[alphabet[i]] = str(blank1) + str(col) + i += 1 + # third row ; header digit is 'blank2' + for col in range(10): + enc[alphabet[i]] = str(blank2) + str(col) + i += 1 + return enc, {v: k for k, v in enc.items()} + + +def __set_params(key, blanks, numeric_key): + return (UC if key == "*" else UC[::-1] if key == "^" else key).upper(), \ + tuple(map(int, str(blanks) or "12")), \ + str(numeric_key or "").rstrip("T"), \ + key == "^", \ + str(numeric_key or " ")[-1] == "T" + + +def vic_encode(key=None, blanks=None, numeric_key=None): + key, blanks, numeric_key, rev, txt = __set_params(key, blanks, numeric_key) + enc_map, dec_map = __build_checkerboard(__build_alphabet(key, reverse=rev), blanks[0], blanks[1]) + def _encode(text, errors="strict"): + _h = handle_error("vic", errors) + digits, nk_i, nk_l = [], 0, len(numeric_key or "") + for pos, c in enumerate(ensure_str(text).upper().replace(" ", "")): + # 1) encode with the straddling checkerboard + c = enc_map[c] if c in enc_map else _h(c, pos, "".join(digits)) + # 2) if numeric_key is defined, over-encrypt digits + if numeric_key and c.isdigit(): + for ci in c: + digits.append(str((int(ci) + int(numeric_key[nk_i % nk_l])) % 10)) + nk_i += 1 + else: + digits.append(c) + r = "".join(d for d in digits if d) + # 3) if text mode, convert digits to text + if txt: + i, r0, l, r = 0, r, len(r), "" + while i < len(r0): + if int(r0[i]) in blanks: + r += dec_map[r0[i] + ("0" if i == l - 1 else r0[i+1])] + i += 1 + else: + r += dec_map[r0[i]] + i += 1 + return r, len(r) + return _encode + + +def vic_decode(key=None, blanks=None, numeric_key=None): + key, blanks, numeric_key, rev, txt = __set_params(key, blanks, numeric_key) + enc_map, dec_map = __build_checkerboard(__build_alphabet(key, reverse=rev), blanks[0], blanks[1]) + def _decode(text, errors="strict"): + _h = handle_error("vic", errors, decode=True) + # 1) if text mode, convert text to digits + text = "".join(enc_map[c] for c in ensure_str(text)) if txt else ensure_str(text) + # 2) if numeric_key is defined, over-decrypt + digits, nk_i, nk_l = [], 0, len(numeric_key or "") + for pos, c in enumerate(text): + if numeric_key and c.isdigit(): + for ci in c: + digits.append(str((int(c) - int(numeric_key[nk_i % nk_l])) % 10)) + nk_i += 1 + else: + digits.append(c if c.isdigit() else _h(c, pos, "".join(digits))) + # 3) decode with the straddling checkerboard + i, r, r0 = 0, "", "".join(d for d in digits if d) + while i < (l := len(r0)): + if int(r0[i]) in blanks: + r += r0[i] if i == l - 1 else dec_map[r0[i] + r0[i+1]] + i += 1 + else: + r += dec_map[r0[i]] if r0[i] in dec_map else _h(r0[i], i, r) + i += 1 + return r, len(r) + return _decode + + +add("vic", vic_encode, vic_decode, r"vic(?:[-_]([\^*]|[./a-zA-Z]+))?(?:[-_]([0-9]{2}))?(?:[-_]([0-9]+T?))?$", + printables_rate=1., penalty=.1) + diff --git a/src/codext/hashing/crypt.py b/src/codext/hashing/crypt.py index f83806f..7513d1e 100644 --- a/src/codext/hashing/crypt.py +++ b/src/codext/hashing/crypt.py @@ -13,10 +13,10 @@ if UNIX: try: - import crypt + import legacycrypt as crypt except ImportError: try: - import legacycrypt as crypt + import crypt except ImportError: crypt = None diff --git a/src/codext/languages/ipsum.py b/src/codext/languages/ipsum.py index a56c197..9fc12bf 100644 --- a/src/codext/languages/ipsum.py +++ b/src/codext/languages/ipsum.py @@ -13,8 +13,9 @@ __examples__ = { - 'enc-dec(ipsum|lorem-ipsum)': ["This is a test !"], + 'dec(lorem_ipsum)': {'orci #': None, 'Auctor babel commodo#': None}, 'enc(ipsum)': {'Bad test#': None}, + 'enc-dec(ipsum|lorem-ipsum)': ["This is a test !"], } @@ -59,7 +60,7 @@ def ipsum_encode(text, errors="strict"): - s, strip = "", False + s, strip, _h = "", False, handle_error("ipsum", errors, " ") for i, c in enumerate(text): try: if c == " " or c in SCHARS: @@ -70,13 +71,12 @@ def ipsum_encode(text, errors="strict"): s += (w.capitalize() if c.isupper() else w) + " " strip = True except KeyError: - s += handle_error("ipsum", errors, " ")(c, i) - return s[:-1] if strip else s, len(text) + s += _h(c, i, s) + return (s := s[:-1] if strip else s), len(s) def ipsum_decode(text, errors="strict"): - s = "" - words = text.split(" ") + s, words, _h = "", text.split(" "), handle_error("ipsum", errors, decode=True, item="word") for i, w in enumerate(words[:-1] if words[-1] == "" else words): if w.strip() == "": s += " " @@ -88,8 +88,8 @@ def ipsum_decode(text, errors="strict"): raise KeyError s += w[:len(w)-len(w.lstrip(SCHARS))] + w.strip(SCHARS)[0] + w[len(w.rstrip(SCHARS)):len(w)] except KeyError: - s += handle_error("ipsum", errors, decode=True, item="word")(w, i) - return s, len(text) + s += _h(w, i, s) + return s, len(s) add("ipsum", ipsum_encode, ipsum_decode, pattern=r"^(?:lorem[-_]?)?ipsum$", printables_rate=1., diff --git a/src/codext/others/uuencode.py b/src/codext/others/uuencode.py index f1ecfc3..597b365 100644 --- a/src/codext/others/uuencode.py +++ b/src/codext/others/uuencode.py @@ -13,10 +13,10 @@ __examples__ = { - 'enc(uu|uu_codec)': {'this is a test': "begin 666 -\n.=&AI 0 and lines[-1].strip(b" \t\r\n\f") in [b"", b"`"]: lines = lines[:-1] r = b"" for l in lines: r += _dec(l.strip(b" \t\r\n\f")) - return r, len(text) + return r, len(r) add("uu", uu_encode, uu_decode, pattern=r"^uu(?:[-_]?encode|[-_]codec)?$", diff --git a/src/codext/stegano/hexagram.py b/src/codext/stegano/hexagram.py index f0ff501..a241637 100644 --- a/src/codext/stegano/hexagram.py +++ b/src/codext/stegano/hexagram.py @@ -11,6 +11,7 @@ __examples__ = { + 'dec(hexagram)': {'䷕䷞䷈䷇t': None}, 'enc(hexagram|iching|i-ching-hexagrams)': {'this is a test': "䷰䷭䷚䷔䷞䷺䷗䷔䷞䷺䷗䷚䷏䷊䷂䷕䷞䷈䷇☯"}, } @@ -25,13 +26,13 @@ def hexagram_encode(input, errors="strict"): def hexagram_decode(input, errors="strict"): - r, ehandler = "", handle_error("hexagram", errors, decode=True) + r, _h = "", handle_error("hexagram", errors, decode=True) for i, c in enumerate(input): try: r += DECMAP[c] except KeyError: - r += ehandler(c, i, r) - return codecs.decode(r, "base64"), len(input) + r += _h(c, i, r) + return (r := codecs.decode(r, "base64")), len(r) add("hexagram", hexagram_encode, hexagram_decode, printables_rate=0., diff --git a/src/codext/stegano/whitespace.py b/src/codext/stegano/whitespace.py index f4343e4..e9e1277 100644 --- a/src/codext/stegano/whitespace.py +++ b/src/codext/stegano/whitespace.py @@ -18,6 +18,11 @@ 'enc(whitespace|whitespaces)': {'test': "\t \t \t\t\t \t\t \t \t \t\t \t \t \t\t"}, 'enc(whitespace-inv|whitespace_inverted)': {'test': " \t\t\t \t \t\t \t \t \t\t\t \t\t \t\t\t \t "}, } +__examples2__ = { + 'dec(whitespace+after-before)': {'} \n a \n . v ': None}, + 'enc(whitespace+after-before)': {'Test\r': None}, + 'enc-dec(whitespace+after-before)': ["Test", "TESTSTRING!"], +} __guess1__ = ["whitespace", "whitespace-inv"] __guess2__ = ["whitespace+after-before", "whitespace-after+before"] @@ -30,42 +35,38 @@ def wsba_encode(p): eq = "ord(c)" + p def encode(text, errors="strict"): - r = [] + r, _h = [], handle_error("whitespace" + p, errors, repl_char="\x00") for i, c in enumerate(text): if ord(c) < min(ord(c) for c in printable[:-6]): - r.append(handle_error("whitespace" + p, errors, repl_char="\x00")(c, i)) - continue - enc = "\x00" - offset = random.randint(-10,10) - while enc not in printable[:-6]: - after = random.randint(0, 20) - before = random.randint(0, 20) - enc = chr(eval(eq) % 256) - r.append(" " * before + enc + " " * after) - s = "\n".join(r) - return s, len(s) + r.append(_h(c, i)) + else: + enc, offset = "\x00", random.randint(-10,10) + while enc not in printable[:-6]: + after, before = random.randint(0, 20), random.randint(0, 20) + enc = chr(eval(eq) % 256) + r.append(" " * before + enc + " " * after) + return (s := "\n".join(r)), len(s) return encode def wsba_decode(p): eq = "ord(c)" + "".join({'-':"+",'+':"-"}.get(c, c) for c in p) def decode(text, errors="strict"): - s = "" + s, _h = "", handle_error("whitespace_after_before", errors, decode=True, item="line") for i, l in enumerate(text.split("\n")): - ll = len(l.strip()) - if ll == 0: + if (ll := len(l.strip())) == 0: continue if ll > 1: - s += handle_error("whitespace_after_before", errors, decode=True, item="line")(l, i) - after = len(l) - len(l.rstrip(" ")) - before = len(l) - len(l.lstrip(" ")) + s += _h(l, i) + after, before = len(l) - len(l.rstrip(" ")), len(l) - len(l.lstrip(" ")) c = l[before] s += chr(eval(eq)) - return s, len(text) + return s, len(s) return decode op = r"[+-](?:\d+(?:\.\d+)?[*/])?" -add("whitespace_after_before", wsba_encode, wsba_decode, guess=__guess2__, entropy=1., printables_rate=1., penalty=.1, - expansion_factor=(22., 3.), pattern=r"whitespace("+op+r"before"+op+r"after|"+op+r"after"+op+r"before)$") +add("whitespace_after_before", wsba_encode, wsba_decode, examples=__examples2__, guess=__guess2__, + printables_rate=1., penalty=.1, expansion_factor=(22., 3.), entropy=1., + pattern=r"whitespace("+op+r"before"+op+r"after|"+op+r"after"+op+r"before)$") diff --git a/tests/test_base.py b/tests/test_base.py index a37d1a6..e024762 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -112,7 +112,30 @@ def test_codec_base8(self): self.assertEqual(codecs.decode(B8, "base8-01234567"), STR) self.assertRaises(LookupError, codecs.encode, "test", "base8-0123456") self.assertRaises(LookupError, codecs.encode, "test", "base8-012345678") - + + def test_codec_base10(self): + B10 = "2361031878030638688519054699098996" + self.assertEqual(codecs.encode(STR, "base10"), B10) + self.assertEqual(codecs.encode(b(STR), "base10"), b(B10)) + self.assertEqual(codecs.decode(B10, "base10"), STR) + self.assertEqual(codecs.decode(b(B10), "base10"), b(STR)) + for alias in ["int", "integer", "dec", "decimal"]: + self.assertEqual(codecs.encode(STR, alias), B10) + self.assertEqual(codecs.decode(B10, alias), STR) + # leading null bytes must be preserved as leading '0' characters + self.assertEqual(codecs.encode("\x00abc", "base10"), "06382179") + self.assertEqual(codecs.encode("\x00", "base10"), "0") + self.assertEqual(codecs.encode("\x00\x00abc", "base10"), "006382179") + self.assertEqual(codecs.decode("06382179", "base10"), "\x00abc") + self.assertEqual(codecs.decode("00", "base10"), "\x00\x00") + self.assertEqual(codecs.encode(b("\x00abc"), "base10"), b("06382179")) + self.assertEqual(codecs.decode(b("06382179"), "base10"), b("\x00abc")) + # a value whose big integer ends in a 0xe nibble must survive decoding + self.assertEqual(codecs.decode(codecs.encode(b"d\xc6\xfe", "base10"), "base10"), b"d\xc6\xfe") + # large inputs must not hit the int<->str conversion digit limit + for data in [b"\x00\xff\xfe", b"\x00\x00", b"\x2a" * 2048]: + self.assertEqual(codecs.decode(codecs.encode(data, "base10"), "base10"), data) + def test_codec_base16(self): B16 = "7468697320697320612074657374" self.assertEqual(codecs.encode(STR, "base16"), B16) @@ -172,7 +195,15 @@ def test_codec_base58(self): self.assertEqual(codecs.decode(B58, "base58-fl"), STR) self.assertEqual(codecs.encode(STR, "base58-short-url"), B58) self.assertEqual(codecs.encode(STR, "base58-url"), B58) - + # leading null bytes must be preserved as leading charset[0] ('1') + self.assertEqual(codecs.encode("\x00abc", "base58"), "1ZiCa") + self.assertEqual(codecs.encode("\x00", "base58"), "1") + self.assertEqual(codecs.encode("\x00\x00abc", "base58"), "11ZiCa") + self.assertEqual(codecs.decode("1ZiCa", "base58"), "\x00abc") + self.assertEqual(codecs.decode("11ZiCa", "base58"), "\x00\x00abc") + self.assertEqual(codecs.encode(b("\x00abc"), "base58"), b("1ZiCa")) + self.assertEqual(codecs.decode(b("1ZiCa"), "base58"), b("\x00abc")) + def test_codec_base62(self): for b62, enc in zip(["CsoB4HQ5gmgMyCenF7E", "M2yLERaFqwqW8MoxPHO"], ["base62", "base62-inv"]): self.assertEqual(codecs.encode(STR, enc), b62) @@ -211,6 +242,19 @@ def test_codec_base100(self): self.assertRaises(ValueError, codecs.decode, b(B100)[1:], "base100") self.assertIsNotNone(codecs.decode(b(B100) + b"\n", "base100", "ignore")) + def test_codec_base45(self): + # RFC 9285 test vectors + for s, b45 in [("AB", "BB8"), ("Hello!!", "%69 VD92EX0"), ("base-45", "UJCLQE7W581")]: + self.assertEqual(codecs.encode(s, "base45"), b45) + self.assertEqual(codecs.encode(b(s), "base45"), b(b45)) + self.assertEqual(codecs.decode(b45, "base45"), s) + self.assertEqual(codecs.decode(b(b45), "base45"), b(s)) + # a trailing non-ASCII byte must not be dropped (byte length, not str length, drives encoding) + self.assertEqual(codecs.encode(b"\xcf\xb1\x1b", "base45"), b"OBQR0") + self.assertEqual(codecs.decode(b"OBQR0", "base45"), b"\xcf\xb1\x1b") + for data in [b"\xff\xfe", b"hello", b"\x00", b"\x80\x81\x82\x83\x84"]: + self.assertEqual(codecs.decode(codecs.encode(data, "base45"), "base45"), data) + def test_codec_base_generic(self): for n in range(2, 255): bn = "base{}_generic".format(n) diff --git a/tests/test_manual.py b/tests/test_manual.py index e443f75..195eecb 100644 --- a/tests/test_manual.py +++ b/tests/test_manual.py @@ -106,6 +106,25 @@ def test_codec_checksum_functions(self): for s, r in [("", ""), ("0", "0"), ("1", "8"), ("7992739871", "3")]: self.assertEqual(codecs.encode(s, "luhn"), r) self.assertEqual(codecs.encode("-", "luhn", errors="ignore"), "") + + def test_codec_crc_bare_defaults(self): + # a crc used without a variant suffix must resolve to that family's default ; + # width 16 and the crc-a family had no default and crashed with KeyError instead + from codext.checksums.crc import CRC + for n in CRC.keys(): + name = "crc%d" % n if isinstance(n, int) else "crc" + expected = "%0{}x".format(round((n or 16)/4+.5)) % CRC[n][""][5] + self.assertEqual(codecs.encode("123456789", name), expected) + self.assertEqual(codecs.encode(b"123456789", name), b(expected)) + # bare crc-16 is CRC-16/ARC (poly 0x8005), the same as its arc/ibm/lha aliases + for name in ["crc16", "crc-16", "crc_16", "crc16-arc", "crc16-ibm", "crc16-lha"]: + self.assertEqual(codecs.encode("123456789", name), "bb3d") + # bare crc is CRC-A, the same as the crca alias + self.assertEqual(codecs.encode("123456789", "crc"), "bf05") + self.assertEqual(codecs.encode("123456789", "crca"), "bf05") + # adding a default must not make an unknown variant resolve + self.assertRaises(LookupError, codecs.encode, "123456789", "crc16-bad") + self.assertRaises(LookupError, codecs.encode, "123456789", "crc-bad") def test_codec_dummy_str_manips(self): STR = "this is a test" @@ -152,8 +171,7 @@ def test_codec_hash_functions(self): METHODS = [x[7:].lower() for x in crypt.__dict__ if x.startswith("METHOD_")] \ if crypt is not None else [] for m in METHODS: - h = "crypt-" + m - self.assertIsNotNone(codecs.encode(STR, h)) + self.assertIsNotNone(codecs.encode(STR, h := f"crypt-{m}")) self.assertRaises(NotImplementedError, codecs.decode, STR, h) def test_codec_markdown(self):