Skip to content

Commit 3f12810

Browse files
committed
Added codec: armon64
1 parent ade8643 commit 3f12810

7 files changed

Lines changed: 169 additions & 86 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,7 @@ This category also contains `ascii85`, `adobe`, `[x]btoa`, `zeromq` with the `ba
349349
- [X] `adfgx`: aka ADFGX Cipher (Polybius square and columnar transposition) ; `adfgvx` for its 6x6 variant
350350
- [X] `aer256`: aka AER-256 (toy cipher with float arithmetic, from online JavaScript tools)
351351
- [X] `affine`: aka Affine Cipher
352+
- [X] `armon64`: aka ARMON-64 (variant of AER-256 with a fourth operation)
352353
- [X] `atbash`: aka Atbash Cipher
353354
- [X] `autoclave`: aka Autoclave/Autokey Cipher (variant of Vigenere Cipher)
354355
- [X] `bacon`: aka Baconian Cipher

‎docs/pages/enc/crypto.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,27 @@ This codec implements the Affine monoalphabetic substitution cipher. It is param
9797

9898
-----
9999

100+
### ARMON-64
101+
102+
This toy cipher, a variant of [AER-256](#aer-256), comes from online JavaScript encryption tools. It works the same way, but with a fourth operation (the number is multiplied by 0.01 times the code of the key character) and with `+` as the separator.
103+
104+
**Codec** | **Conversions** | **Aliases** | **Comment**
105+
:---: | :---: | --- | ---
106+
`armon64` | text <-> ARMON-64 ciphertext | `armon-64`, `armon_64` | parameterized with a mandatory key of at least 3 characters, e.g. `armon64-secret` ; only characters with a code below 256
107+
108+
!!! warning "Precision"
109+
110+
As with AER-256, keys of more than 12 characters give blocks that are too large to be decrypted exactly. Unlike the original JavaScript, decryption does not split numbers in exponent notation (e.g. `1e+21`) on their `+`.
111+
112+
```python
113+
>>> codext.encode("this is a test", "armon64-test")
114+
'210.18970297029702+177.9738613861386+-36.77544554455446+206.4225742574257+153.49900990099007+210.15524752475247+207.38732673267327'
115+
>>> codext.decode("210.18970297029702+177.9738613861386", "armon64-test")
116+
'this'
117+
```
118+
119+
-----
120+
100121
### Atbash Cipher
101122

102123
It implements the monoalphabetic substitution cipher used for the Hebrew alphabet. By default, it considers the lowercase and uppercase letters, inverted per group, as the alphabet. It can also use a mask to extend it. Note that it does not generate any error for characters that are not part of the alphabet.

‎src/codext/crypto/__init__.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from .adfgx import *
33
from .aer256 import *
44
from .affine import *
5+
from .armon64 import *
56
from .atbash import *
67
from .bacon import *
78
from .barbie import *

‎src/codext/crypto/_jsfloat.py‎

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# -*- coding: UTF-8 -*-
2+
"""Floating-point block ciphers, shared by the toy ciphers of online JavaScript encryption tools (AER-256, ARMON-64).
3+
4+
The text is split into blocks of half the key length (rounded up), each block being read as a big-endian number of its
5+
character codes. For each character of the key (at least 3), an operation with its code is applied to this number in
6+
turn (in floating-point arithmetic, cycling through the operations of the cipher), and the resulting numbers are
7+
written as JavaScript would (e.g. "123.456") and joined with a separator. Decryption applies the inverse operations in
8+
reverse order and rounds the result. As numbers are double-precision floats, blocks of more than 6 characters (i.e.
9+
keys of more than 12 characters) lose precision and generally cannot be fully decrypted. Only characters with a code
10+
below 256 can be encrypted, and null characters at the start of a block are lost.
11+
"""
12+
import re
13+
from math import floor, isfinite
14+
15+
from ..__common__ import ensure_str, handle_error
16+
17+
18+
# operations of the ciphers with their inverses, as (encryption, decryption) pairs
19+
OPERATIONS = [
20+
(lambda v, k: v + k, lambda v, k: v - k),
21+
(lambda v, k: v / k, lambda v, k: v * k),
22+
(lambda v, k: v - k, lambda v, k: v + k),
23+
(lambda v, k: v * (.01 * k), lambda v, k: v / (.01 * k)),
24+
]
25+
26+
27+
def js_str(x):
28+
""" Convert a float to a string like JavaScript's Number.prototype.toString. """
29+
x = float(x)
30+
if x != x:
31+
return "NaN"
32+
if x < 0:
33+
return "-" + js_str(-x)
34+
if x == 0:
35+
return "0"
36+
if not isfinite(x):
37+
return "Infinity"
38+
# shortest digits that round-trip (as with repr) and decimal exponent n such that x = 0.digits * 10^n
39+
m, _, e = repr(x).partition("e")
40+
i, _, f = m.partition(".")
41+
d = (i + f.rstrip("0")) if f != "0" else i
42+
n = len(i) + int(e or 0)
43+
k = len(d.lstrip("0"))
44+
n -= len(d) - k
45+
d = d.lstrip("0")
46+
if k <= n <= 21:
47+
return d + "0" * (n - k)
48+
if 0 < n <= 21:
49+
return d[:n] + "." + d[n:]
50+
if -6 < n <= 0:
51+
return "0." + "0" * -n + d
52+
return d[0] + ("." + d[1:] if k > 1 else "") + "e" + "+-"[n < 1] + str(abs(n - 1))
53+
54+
55+
def float_cipher(name, sep, n_ops):
56+
""" Make the encoding and decoding function factories of a cipher, given its separator and its number of
57+
operations. """
58+
# the separator is not matched inside numbers in exponent notation (e.g. "1e+21")
59+
split = re.compile(r"(?<![eE])" + re.escape(sep))
60+
61+
def _key(key):
62+
key = str(key or "")
63+
if len(key) < 3:
64+
raise LookupError(f"Bad parameter for encoding '{name}': key must be at least 3 characters long")
65+
return [ord(c) for c in key], (len(key) + 1) // 2
66+
67+
def encode_factory(key=None):
68+
def encode(text, errors="strict"):
69+
k, n = _key(key)
70+
_h, t = handle_error(name, errors), ensure_str(text)
71+
t = "".join(c for c in "".join(c if ord(c) < 256 else _h(c, i) for i, c in enumerate(t)) if ord(c) < 256)
72+
r = []
73+
for i in range(0, len(t), n):
74+
v = float(int("".join(f"{ord(c):02x}" for c in t[i:i+n]), 16))
75+
for j, kc in enumerate(k):
76+
v = OPERATIONS[j % n_ops][0](v, kc)
77+
r.append(js_str(v))
78+
return sep.join(r), len(text)
79+
return encode
80+
81+
def decode_factory(key=None):
82+
def decode(text, errors="strict"):
83+
k, _ = _key(key)
84+
_h, t, r = handle_error(name, errors, decode=True, kind="number"), ensure_str(text), ""
85+
for i, s in enumerate(split.split(t) if t else []):
86+
try:
87+
v = float(s)
88+
except ValueError:
89+
r += _h(s, i)
90+
continue
91+
for j in range(len(k) - 1, -1, -1):
92+
v = OPERATIONS[j % n_ops][1](v, k[j])
93+
# Math.round, then the hexadecimal digits of the number (nothing when it is not positive)
94+
v = floor(v) + (v - floor(v) >= .5) if isfinite(v) else 0
95+
h = f"{v:x}" if v > 0 else ""
96+
h = "0" * (len(h) % 2) + h
97+
r += "".join(chr(int(h[j:j+2], 16)) for j in range(0, len(h), 2))
98+
return r, len(text)
99+
return decode
100+
101+
return encode_factory, decode_factory

‎src/codext/crypto/aer256.py‎

Lines changed: 3 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,8 @@
1717
1818
Reference: https://github.com/gungunfebrianza/Mastering-Cryptography/blob/master/AER-256/AER-256.js
1919
"""
20-
from math import floor, isfinite
21-
22-
from ..__common__ import *
20+
from ._jsfloat import float_cipher
21+
from ..__common__ import add
2322

2423

2524
__examples__ = {
@@ -33,85 +32,4 @@
3332
}
3433

3534

36-
def _js_str(x):
37-
""" Convert a float to a string like JavaScript's Number.prototype.toString. """
38-
x = float(x)
39-
if x != x:
40-
return "NaN"
41-
if x < 0:
42-
return "-" + _js_str(-x)
43-
if x == 0:
44-
return "0"
45-
if not isfinite(x):
46-
return "Infinity"
47-
# shortest digits that round-trip (as with repr) and decimal exponent n such that x = 0.digits * 10^n
48-
m, _, e = repr(x).partition("e")
49-
i, _, f = m.partition(".")
50-
d = (i + f.rstrip("0")) if f != "0" else i
51-
n = len(i) + int(e or 0)
52-
k = len(d.lstrip("0"))
53-
n -= len(d) - k
54-
d = d.lstrip("0")
55-
if k <= n <= 21:
56-
return d + "0" * (n - k)
57-
if 0 < n <= 21:
58-
return d[:n] + "." + d[n:]
59-
if -6 < n <= 0:
60-
return "0." + "0" * -n + d
61-
return d[0] + ("." + d[1:] if k > 1 else "") + "e" + "+-"[n < 1] + str(abs(n - 1))
62-
63-
64-
def __check_key(key):
65-
key = str(key or "")
66-
if len(key) < 3:
67-
raise LookupError("Bad parameter for encoding 'aer256': key must be at least 3 characters long")
68-
return [ord(c) for c in key], (len(key) + 1) // 2
69-
70-
71-
def aer256_encode(key=None):
72-
def encode(text, errors="strict"):
73-
k, n = __check_key(key)
74-
_h, t = handle_error("aer256", errors), ensure_str(text)
75-
t = "".join(c for c in "".join(c if ord(c) < 256 else _h(c, i) for i, c in enumerate(t)) if ord(c) < 256)
76-
r = []
77-
for i in range(0, len(t), n):
78-
v = float(int("".join(f"{ord(c):02x}" for c in t[i:i+n]), 16))
79-
for j, kc in enumerate(k):
80-
if j % 3 == 0:
81-
v += kc
82-
elif j % 3 == 1:
83-
v /= kc
84-
else:
85-
v -= kc
86-
r.append(_js_str(v))
87-
return ", ".join(r), len(text)
88-
return encode
89-
90-
91-
def aer256_decode(key=None):
92-
def decode(text, errors="strict"):
93-
k, _ = __check_key(key)
94-
_h, t, r = handle_error("aer256", errors, decode=True, kind="number"), ensure_str(text), ""
95-
for i, s in enumerate(t.split(", ") if t else []):
96-
try:
97-
v = float(s)
98-
except ValueError:
99-
r += _h(s, i)
100-
continue
101-
for j in range(len(k) - 1, -1, -1):
102-
if j % 3 == 0:
103-
v -= k[j]
104-
elif j % 3 == 1:
105-
v *= k[j]
106-
else:
107-
v += k[j]
108-
# Math.round, then the hexadecimal digits of the number (nothing when it is not positive)
109-
v = floor(v) + (v - floor(v) >= .5) if isfinite(v) else 0
110-
h = f"{v:x}" if v > 0 else ""
111-
h = "0" * (len(h) % 2) + h
112-
r += "".join(chr(int(h[j:j+2], 16)) for j in range(0, len(h), 2))
113-
return r, len(text)
114-
return decode
115-
116-
117-
add("aer256", aer256_encode, aer256_decode, r"^aer[-_]?256(?:[-_](\S*))?$", guess=None)
35+
add("aer256", *float_cipher("aer256", ", ", 3), r"^aer[-_]?256(?:[-_](\S*))?$", guess=None)

‎src/codext/crypto/armon64.py‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# -*- coding: UTF-8 -*-
2+
"""ARMON-64 Codec - ARMON-64 content encryption.
3+
4+
ARMON-64 is a toy cipher found on online JavaScript encryption tools, a variant of AER-256. The text is split into
5+
blocks of half the key length (rounded up), each block being read as a big-endian number of its character codes. For
6+
each character of the key (at least 3), its code is added to, divides, is subtracted from or (multiplied by 0.01)
7+
multiplies this number in turn (in floating-point arithmetic), and the resulting numbers are written as JavaScript
8+
would (e.g. "123.456") and separated by "+" (numbers in exponent notation, e.g. "1e+21", being kept whole when
9+
decrypting, unlike in the original JavaScript). Decryption applies the inverse operations in reverse order and rounds
10+
the result. As numbers are double-precision floats, blocks of more than 6 characters (i.e. keys of more than 12
11+
characters) lose precision and generally cannot be fully decrypted. Only characters with a code below 256 can be
12+
encrypted, and null characters at the start of a block are lost.
13+
14+
This codec:
15+
- en/decodes strings from str to str
16+
- en/decodes strings from bytes to bytes
17+
- decodes file content to str (read)
18+
- encodes file content from str to bytes (write)
19+
20+
Reference: https://github.com/gungunfebrianza/Mastering-Cryptography/blob/master/ARMON-64/ARMON-64.js
21+
"""
22+
from ._jsfloat import float_cipher
23+
from ..__common__ import add
24+
25+
26+
__examples__ = {
27+
'enc(armon64|armon64-ab)': None,
28+
'enc(armon64-test|armon-64_test)': {'this is a test': "210.18970297029702+177.9738613861386+-36.77544554455446+"
29+
"206.4225742574257+153.49900990099007+210.15524752475247+"
30+
"207.38732673267327"},
31+
'dec(armon64-test)': {'210.18970297029702+177.9738613861386': "this", '210.1+x': None},
32+
'enc-dec(armon64-key|armon64-secret|armon64-K3y!|armon64-123456789012)': ["", "this is a test",
33+
"Hello, World! 123", "\xe9t\xe9 \xff"],
34+
}
35+
36+
37+
add("armon64", *float_cipher("armon64", "+", 4), r"^armon[-_]?64(?:[-_](\S*))?$", guess=None)

‎tests/test_manual.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ def test_codec_sms(self):
8080

8181
class ManualTestCase(TestCase):
8282
def test_codec_aer256(self):
83-
from codext.crypto.aer256 import _js_str
83+
from codext.crypto._jsfloat import js_str as _js_str
8484
# numbers formatted as with JavaScript's String(...), as given by Node.js
8585
for x, r in [(0., "0"), (-0., "0"), (1e21, "1e+21"), (123456789012345680000., "123456789012345680000"),
8686
(1e-6, "0.000001"), (1e-7, "1e-7"), (-1.2345e-7, "-1.2345e-7"), (2**60, "1152921504606847000"),
@@ -92,6 +92,10 @@ def test_codec_aer256(self):
9292
# with a long key, blocks exceed the precision of floats
9393
self.assertNotEqual(codecs.decode(codecs.encode("this is a test", k := "aer256-thisisaverylongkey"), k),
9494
"this is a test")
95+
# with ARMON-64, numbers in exponent notation are not split on their "+"
96+
v = round((1e21 + 99) * 98 - 97)
97+
self.assertEqual(codecs.decode("1e+21", "armon64-abc"), bytes.fromhex(f"{v:x}").decode("latin-1"))
98+
self.assertEqual(codecs.decode("1e+21+1e+21", "armon64-abc"), 2 * codecs.decode("1e+21", "armon64-abc"))
9599

96100
def test_codec_affine(self):
97101
STR = "this is a test"

0 commit comments

Comments
 (0)