yace-codec 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- yace_codec/__init__.py +71 -0
- yace_codec/__main__.py +6 -0
- yace_codec/cli.py +276 -0
- yace_codec/codec.py +498 -0
- yace_codec/errors.py +65 -0
- yace_codec/keystore.py +554 -0
- yace_codec/py.typed +0 -0
- yace_codec/tables.py +324 -0
- yace_codec-0.1.0.dist-info/METADATA +701 -0
- yace_codec-0.1.0.dist-info/RECORD +14 -0
- yace_codec-0.1.0.dist-info/WHEEL +5 -0
- yace_codec-0.1.0.dist-info/entry_points.txt +2 -0
- yace_codec-0.1.0.dist-info/licenses/LICENSE +21 -0
- yace_codec-0.1.0.dist-info/top_level.txt +1 -0
yace_codec/__init__.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""YACE (Yet Another Custom Encoder): a reversible text encoder built on random
|
|
2
|
+
substitution tables.
|
|
3
|
+
|
|
4
|
+
>>> import yace_codec as yace
|
|
5
|
+
>>> codec = yace.Codec(yace.KeyStore.in_memory())
|
|
6
|
+
>>> token = codec.encode("Hello, World!")
|
|
7
|
+
>>> codec.decode(token)
|
|
8
|
+
'Hello, World!'
|
|
9
|
+
|
|
10
|
+
YACE obfuscates text. It is not encryption: do not use it to protect secrets.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .codec import (
|
|
14
|
+
DEFAULT_FAMILY,
|
|
15
|
+
DEFAULT_ITERATIONS,
|
|
16
|
+
DEFAULT_LENGTH,
|
|
17
|
+
MAX_ENCODED_CHARS,
|
|
18
|
+
MAX_ITERATIONS,
|
|
19
|
+
Codec,
|
|
20
|
+
Header,
|
|
21
|
+
decode,
|
|
22
|
+
decode_bytes,
|
|
23
|
+
encode,
|
|
24
|
+
encode_bytes,
|
|
25
|
+
parse_header,
|
|
26
|
+
)
|
|
27
|
+
from .errors import (
|
|
28
|
+
DecodingError,
|
|
29
|
+
EncodingError,
|
|
30
|
+
InvalidNameError,
|
|
31
|
+
KeyStoreError,
|
|
32
|
+
TableNotFoundError,
|
|
33
|
+
UnsupportedCharacterError,
|
|
34
|
+
YaceError,
|
|
35
|
+
)
|
|
36
|
+
from .keystore import ENV_VAR, Family, ImportResult, KeyStore, default_keystore_path
|
|
37
|
+
from .tables import MAX_LENGTH, MIN_LENGTH, SUPPORTED_CHARACTERS, Table
|
|
38
|
+
|
|
39
|
+
__version__ = "0.1.0"
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"DEFAULT_FAMILY",
|
|
43
|
+
"DEFAULT_ITERATIONS",
|
|
44
|
+
"DEFAULT_LENGTH",
|
|
45
|
+
"ENV_VAR",
|
|
46
|
+
"MAX_ENCODED_CHARS",
|
|
47
|
+
"MAX_ITERATIONS",
|
|
48
|
+
"MAX_LENGTH",
|
|
49
|
+
"MIN_LENGTH",
|
|
50
|
+
"SUPPORTED_CHARACTERS",
|
|
51
|
+
"Codec",
|
|
52
|
+
"DecodingError",
|
|
53
|
+
"EncodingError",
|
|
54
|
+
"Family",
|
|
55
|
+
"Header",
|
|
56
|
+
"ImportResult",
|
|
57
|
+
"InvalidNameError",
|
|
58
|
+
"KeyStore",
|
|
59
|
+
"KeyStoreError",
|
|
60
|
+
"Table",
|
|
61
|
+
"TableNotFoundError",
|
|
62
|
+
"UnsupportedCharacterError",
|
|
63
|
+
"YaceError",
|
|
64
|
+
"__version__",
|
|
65
|
+
"decode",
|
|
66
|
+
"decode_bytes",
|
|
67
|
+
"default_keystore_path",
|
|
68
|
+
"encode",
|
|
69
|
+
"encode_bytes",
|
|
70
|
+
"parse_header",
|
|
71
|
+
]
|
yace_codec/__main__.py
ADDED
yace_codec/cli.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Command-line interface: ``yace-codec`` (or ``python -m yace_codec``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import contextlib
|
|
7
|
+
import errno
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Callable, Dict, List, Optional, Sequence
|
|
13
|
+
|
|
14
|
+
from . import __version__
|
|
15
|
+
from .codec import DEFAULT_FAMILY, DEFAULT_ITERATIONS, DEFAULT_LENGTH, Codec
|
|
16
|
+
from .errors import KeyStoreError, UnsupportedCharacterError, YaceError
|
|
17
|
+
from .keystore import ENV_VAR, KeyStore, family_of
|
|
18
|
+
|
|
19
|
+
PROG = "yace-codec"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
23
|
+
"""Run the command line and return the exit status."""
|
|
24
|
+
parser = _build_parser()
|
|
25
|
+
args = parser.parse_args(argv)
|
|
26
|
+
if getattr(args, "text", None) is not None and args.input != "-":
|
|
27
|
+
parser.error("give either INPUT or --text, not both")
|
|
28
|
+
handler: Callable[[argparse.Namespace], int] = args.handler
|
|
29
|
+
try:
|
|
30
|
+
return handler(args)
|
|
31
|
+
except UnsupportedCharacterError as exc:
|
|
32
|
+
shown = ", ".join(f"{ascii(ch)} (U+{ord(ch):04X})" for ch in exc.characters[:10])
|
|
33
|
+
if len(exc.characters) > 10:
|
|
34
|
+
shown += f" and {len(exc.characters) - 10} more"
|
|
35
|
+
return _fail(f"table {exc.table_name!r} cannot encode {shown}; re-run with --binary")
|
|
36
|
+
except UnicodeDecodeError:
|
|
37
|
+
return _fail("input is not valid UTF-8 text; re-run with --binary")
|
|
38
|
+
except YaceError as exc:
|
|
39
|
+
return _fail(str(exc))
|
|
40
|
+
except OSError as exc:
|
|
41
|
+
if isinstance(exc, BrokenPipeError):
|
|
42
|
+
_silence_stdout()
|
|
43
|
+
return 1
|
|
44
|
+
target = f"{exc.filename}: " if exc.filename else ""
|
|
45
|
+
return _fail(f"{target}{exc.strerror or exc}")
|
|
46
|
+
except KeyboardInterrupt:
|
|
47
|
+
return 130
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
51
|
+
store = argparse.ArgumentParser(add_help=False)
|
|
52
|
+
store.add_argument(
|
|
53
|
+
"-k",
|
|
54
|
+
"--keystore",
|
|
55
|
+
metavar="PATH",
|
|
56
|
+
help=f"key store file (default: ${ENV_VAR} if set, else a file in your user data "
|
|
57
|
+
"directory; see 'yace-codec tables path')",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
parser = argparse.ArgumentParser(
|
|
61
|
+
prog=PROG,
|
|
62
|
+
description="Encode and decode text with YACE substitution tables. "
|
|
63
|
+
"YACE obfuscates text; it is not encryption.",
|
|
64
|
+
)
|
|
65
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
66
|
+
commands = parser.add_subparsers(title="commands", metavar="COMMAND")
|
|
67
|
+
commands.required = True
|
|
68
|
+
|
|
69
|
+
enc = commands.add_parser("encode", parents=[store], help="encode text or a file")
|
|
70
|
+
enc.add_argument("input", nargs="?", default="-", help="file to encode (default: stdin)")
|
|
71
|
+
enc.add_argument("-t", "--text", help="encode this string instead of INPUT")
|
|
72
|
+
enc.add_argument("-o", "--output", metavar="PATH", help="write the token here (default: stdout)")
|
|
73
|
+
enc.add_argument(
|
|
74
|
+
"-n", "--name", default=DEFAULT_FAMILY, help="table family name (default: %(default)s)"
|
|
75
|
+
)
|
|
76
|
+
enc.add_argument(
|
|
77
|
+
"-l", "--length", type=int, default=DEFAULT_LENGTH,
|
|
78
|
+
help="code length, 2-62 (default: %(default)s)",
|
|
79
|
+
)
|
|
80
|
+
enc.add_argument(
|
|
81
|
+
"-i", "--iterations", type=int, default=DEFAULT_ITERATIONS,
|
|
82
|
+
help="number of encoding layers (default: %(default)s)",
|
|
83
|
+
)
|
|
84
|
+
enc.add_argument(
|
|
85
|
+
"-b", "--binary", action="store_true",
|
|
86
|
+
help="encode the input as raw bytes (any file type or text encoding)",
|
|
87
|
+
)
|
|
88
|
+
enc.set_defaults(handler=_cmd_encode)
|
|
89
|
+
|
|
90
|
+
dec = commands.add_parser("decode", parents=[store], help="decode a token")
|
|
91
|
+
dec.add_argument("input", nargs="?", default="-", help="file holding the token (default: stdin)")
|
|
92
|
+
dec.add_argument("-t", "--text", help="decode this token string instead of INPUT")
|
|
93
|
+
dec.add_argument("-o", "--output", metavar="PATH", help="write the result here (default: stdout)")
|
|
94
|
+
dec.add_argument(
|
|
95
|
+
"-b", "--binary", action="store_true",
|
|
96
|
+
help="the token was made with 'encode --binary'; output the raw bytes",
|
|
97
|
+
)
|
|
98
|
+
dec.set_defaults(handler=_cmd_decode)
|
|
99
|
+
|
|
100
|
+
tables = commands.add_parser("tables", help="inspect and share key store tables")
|
|
101
|
+
actions = tables.add_subparsers(title="actions", metavar="ACTION")
|
|
102
|
+
actions.required = True
|
|
103
|
+
path = actions.add_parser("path", parents=[store], help="print the key store location")
|
|
104
|
+
path.set_defaults(handler=_cmd_path)
|
|
105
|
+
listing = actions.add_parser("list", parents=[store], help="list table families")
|
|
106
|
+
listing.set_defaults(handler=_cmd_list)
|
|
107
|
+
export = actions.add_parser("export", parents=[store], help="export a family's tables as JSON")
|
|
108
|
+
export.add_argument("name", help="family name")
|
|
109
|
+
export.add_argument("-o", "--output", metavar="PATH", help="write here (default: stdout)")
|
|
110
|
+
export.set_defaults(handler=_cmd_export)
|
|
111
|
+
imp = actions.add_parser("import", parents=[store], help="import tables from a JSON file")
|
|
112
|
+
imp.add_argument("file", help="JSON file from 'tables export' ('-' for stdin)")
|
|
113
|
+
which = imp.add_mutually_exclusive_group()
|
|
114
|
+
which.add_argument("--family", metavar="NAME", help="import only this family's tables")
|
|
115
|
+
which.add_argument(
|
|
116
|
+
"--all-families", action="store_true",
|
|
117
|
+
help="import every table in a file that holds several families",
|
|
118
|
+
)
|
|
119
|
+
imp.add_argument(
|
|
120
|
+
"--overwrite", action="store_true",
|
|
121
|
+
help="replace existing tables that differ (tokens made with them stop decoding)",
|
|
122
|
+
)
|
|
123
|
+
imp.set_defaults(handler=_cmd_import)
|
|
124
|
+
return parser
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _keystore(args: argparse.Namespace) -> KeyStore:
|
|
128
|
+
return KeyStore(args.keystore) if args.keystore else KeyStore.default()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _read_input(args: argparse.Namespace) -> bytes:
|
|
132
|
+
if args.text is not None:
|
|
133
|
+
# The argument's original bytes: on POSIX, bytes that are not valid
|
|
134
|
+
# UTF-8 arrive as surrogates, which os.fsencode turns back into bytes.
|
|
135
|
+
return os.fsencode(args.text)
|
|
136
|
+
if args.input == "-":
|
|
137
|
+
return sys.stdin.buffer.read()
|
|
138
|
+
return _read_file(args.input)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _read_file(name: str) -> bytes:
|
|
142
|
+
if os.path.isdir(name):
|
|
143
|
+
raise IsADirectoryError(errno.EISDIR, "is a directory", name)
|
|
144
|
+
return Path(name).read_bytes()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _write_output(target: Optional[str], data: bytes, *, private: bool = False) -> None:
|
|
148
|
+
if target is None or target == "-":
|
|
149
|
+
_write_stdout(data)
|
|
150
|
+
return
|
|
151
|
+
if os.path.isdir(target):
|
|
152
|
+
raise IsADirectoryError(errno.EISDIR, "is a directory", target)
|
|
153
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_BINARY", 0)
|
|
154
|
+
fd = os.open(target, flags, 0o600 if private else 0o666)
|
|
155
|
+
if private and sys.platform != "win32":
|
|
156
|
+
os.fchmod(fd, 0o600) # the open() mode only applies to new files
|
|
157
|
+
with os.fdopen(fd, "wb") as fh:
|
|
158
|
+
fh.write(data)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _cmd_encode(args: argparse.Namespace) -> int:
|
|
162
|
+
codec = Codec(_keystore(args))
|
|
163
|
+
raw = _read_input(args)
|
|
164
|
+
options = dict(length=args.length, iterations=args.iterations)
|
|
165
|
+
if args.binary:
|
|
166
|
+
token = codec.encode_bytes(raw, args.name, **options)
|
|
167
|
+
else:
|
|
168
|
+
token = codec.encode(raw.decode("utf-8"), args.name, **options)
|
|
169
|
+
_write_output(args.output, f"{token}\n".encode("ascii"))
|
|
170
|
+
return 0
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _cmd_decode(args: argparse.Namespace) -> int:
|
|
174
|
+
codec = Codec(_keystore(args))
|
|
175
|
+
raw = _read_input(args)
|
|
176
|
+
if args.binary:
|
|
177
|
+
result = codec.decode_bytes(raw)
|
|
178
|
+
else:
|
|
179
|
+
result = codec.decode(raw).encode("utf-8")
|
|
180
|
+
_write_output(args.output, result)
|
|
181
|
+
return 0
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _cmd_path(args: argparse.Namespace) -> int:
|
|
185
|
+
_echo(str(_keystore(args).path))
|
|
186
|
+
return 0
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _cmd_list(args: argparse.Namespace) -> int:
|
|
190
|
+
store = _keystore(args)
|
|
191
|
+
families = store.families()
|
|
192
|
+
if not families:
|
|
193
|
+
_echo(f"no tables in {store.path}", err=True)
|
|
194
|
+
return 0
|
|
195
|
+
rows: List[Dict[str, str]] = [
|
|
196
|
+
{"name": f.name, "length": str(f.length), "layers": ",".join(map(str, f.layers))}
|
|
197
|
+
for f in families
|
|
198
|
+
]
|
|
199
|
+
width = max(len("NAME"), *(len(r["name"]) for r in rows))
|
|
200
|
+
_echo(f"{'NAME':<{width}} LENGTH LAYERS")
|
|
201
|
+
for r in rows:
|
|
202
|
+
_echo(f"{r['name']:<{width}} {r['length']:<6} {r['layers']}")
|
|
203
|
+
return 0
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _cmd_export(args: argparse.Namespace) -> int:
|
|
207
|
+
tables = _keystore(args).export_family(args.name)
|
|
208
|
+
payload = json.dumps(tables, indent=2, ensure_ascii=True) + "\n"
|
|
209
|
+
_write_output(args.output, payload.encode("ascii"), private=True)
|
|
210
|
+
return 0
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _cmd_import(args: argparse.Namespace) -> int:
|
|
214
|
+
raw = sys.stdin.buffer.read() if args.file == "-" else _read_file(args.file)
|
|
215
|
+
try:
|
|
216
|
+
# json.loads detects UTF-8 (with or without a BOM), UTF-16, and UTF-32,
|
|
217
|
+
# so files saved by Windows PowerShell 5.1 work too.
|
|
218
|
+
tables = json.loads(raw)
|
|
219
|
+
except (ValueError, RecursionError) as exc:
|
|
220
|
+
raise KeyStoreError(f"{args.file} is not valid JSON ({exc})") from exc
|
|
221
|
+
if isinstance(tables, dict) and args.family is None and not args.all_families:
|
|
222
|
+
# A shared file could add tables for families you use, such as "default",
|
|
223
|
+
# so that its sender can decode your future tokens: name the family you expect.
|
|
224
|
+
families = sorted({f for f in map(family_of, tables) if f is not None})
|
|
225
|
+
held = ", ".join(families) or "no valid tables"
|
|
226
|
+
raise KeyStoreError(
|
|
227
|
+
f"{args.file} holds tables for: {held}. Name the family you expect with "
|
|
228
|
+
"--family NAME, or import everything with --all-families"
|
|
229
|
+
)
|
|
230
|
+
store = _keystore(args)
|
|
231
|
+
result = store.import_tables(tables, overwrite=args.overwrite, family=args.family)
|
|
232
|
+
for label, names in (("added", result.added), ("replaced", result.replaced)):
|
|
233
|
+
for name in names:
|
|
234
|
+
_echo(f"{label}: {name}", err=True)
|
|
235
|
+
_echo(
|
|
236
|
+
f"{len(result.added)} added, {len(result.unchanged)} unchanged, "
|
|
237
|
+
f"{len(result.replaced)} replaced ({args.keystore or store.path})",
|
|
238
|
+
err=True,
|
|
239
|
+
)
|
|
240
|
+
return 0
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _fail(message: str) -> int:
|
|
244
|
+
_echo(f"{PROG}: error: {message}", err=True)
|
|
245
|
+
return 1
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _echo(text: str, *, err: bool = False) -> None:
|
|
249
|
+
# Write UTF-8 bytes so redirected output never fails on the console code page.
|
|
250
|
+
data = (text + "\n").encode("utf-8", "backslashreplace")
|
|
251
|
+
if not err:
|
|
252
|
+
_write_stdout(data)
|
|
253
|
+
return
|
|
254
|
+
with contextlib.suppress(OSError): # nowhere left to report a failure
|
|
255
|
+
sys.stderr.flush()
|
|
256
|
+
sys.stderr.buffer.write(data)
|
|
257
|
+
sys.stderr.buffer.flush()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _write_stdout(data: bytes) -> None:
|
|
261
|
+
try:
|
|
262
|
+
sys.stdout.flush()
|
|
263
|
+
sys.stdout.buffer.write(data)
|
|
264
|
+
sys.stdout.buffer.flush()
|
|
265
|
+
except OSError as exc:
|
|
266
|
+
# A reader that closed the pipe: EPIPE on POSIX, EINVAL on Windows.
|
|
267
|
+
if isinstance(exc, BrokenPipeError) or exc.errno == errno.EINVAL:
|
|
268
|
+
raise BrokenPipeError(errno.EPIPE, "broken pipe") from exc
|
|
269
|
+
raise
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _silence_stdout() -> None:
|
|
273
|
+
# Python flushes stdout at exit; point it at devnull so a closed pipe stays quiet.
|
|
274
|
+
with contextlib.suppress(OSError, ValueError):
|
|
275
|
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
276
|
+
os.dup2(devnull, sys.stdout.fileno())
|