fiddlesticks 0.0.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.
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.5
2
+ Name: fiddlesticks
3
+ Version: 0.0.0
4
+ Summary: Password recovery tool for encrypted archives (currently only .7z files supported).
5
+ Project-URL: GitHub, https://github.com/Hazardous-Area/fiddlesticks
6
+ Author-email: James Parrott <james@jamesparrott.dev>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.12
10
+ Provides-Extra: py7zr
11
+ Requires-Dist: py7zr; extra == 'py7zr'
12
+ Provides-Extra: tests
13
+ Requires-Dist: hypothesis; extra == 'tests'
14
+ Requires-Dist: pytest; extra == 'tests'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # Fiddlesticks!
18
+ *"Aaaagh! I forgot my 7zip password"* - James (more times than he cares to remember).
19
+
20
+ ## Description
21
+ Password recovery tool, for password-encrypted files, using simple off-line brute
22
+ force attacks. Password candidates are generated, using common variations
23
+ of a guessed password (e.g. typos and substitutions).
24
+
25
+ ### Usage
26
+ - Password-protected file owners recovering their own password themselves, as long as
27
+ they can still recall a rough guess for their password, might only need to test
28
+ every candidate password that's similar enough to the guess.
29
+ - This may be a much faster and cheaper computation
30
+ than the one an adversary must do, without such a guess, but in posession
31
+ of a stolen password protected file[^0].
32
+
33
+ ### "Back of envelope" sketch 'calculation'
34
+ - Attackers targetting a truly[^0] random password, must try up to `2**N`
35
+ candidate passwords (for each bit length `N` being considered).
36
+ - Specifically, password owners may only need to consider every candidate within some
37
+ maximum [Weighted-Levenshtein distance](https://en.wikipedia.org/wiki/Edit_distance#Types_of_edit_distance)
38
+ from their best guess of the forgotten password, lets say a total of `M`.
39
+ - Fiddlesticks is intended to assist recovering passwords from "close enough" guesses,
40
+ when `M` is much smaller than `2**N`.
41
+ - If Fiddlesticks can crack an archive's password with a starting guess of an empty string,
42
+ (if `2**N` is also small enough to be feasible, with no guess) then anyone with the
43
+ archive can also do so - the password wasn't strong enough.
44
+ - If Fiddlesticks fails to crack an archive's password given some starting guess, a lower
45
+ bound on how similar the actual password is to the starting guess can still be deduced
46
+ (e.g. this could indicate that the starting guess was wrong).
47
+
48
+ ### Design and security notes
49
+ - Any similar 3rd party password cracking service based on 'best guess' passwords, requires
50
+ the user to share the guesses for their passwords with the service. Even if the password
51
+ was not used for anything else, sharing even guesses for secret credentials with 3rd parties,
52
+ is a critical security issue.
53
+ - Fiddlesticks is designed to minimise the need for this. It is designed to:
54
+ - a) require as few dependencies as possible,
55
+ - b) be as easy to install as possible.
56
+ The intention is firstly b) assists users to run Fiddlesticks in their own secure
57
+ environment, without requiring them to take their password guesses outside of that. Secondly
58
+ a) helps them decide for themselves whether or not to trust Fiddlesticks
59
+ in the first place, in particular whether or not it will take their password guesses outside
60
+ of its running environment. When the project was concieved, the intention was also to design it to:
61
+ - c) require as little code as possible
62
+ but the code has since become somewhat more complex, mainly in order to have a nice CLI. Simplicity and brevity should be much more highly prized features of any software. But you
63
+ be the judge of whether or not this c) is still the case.
64
+
65
+ - Recommended use is simply to automate attempts to open a 7z archive via the user's own 7zip.
66
+ - There are a couple of alternative modes too, firstly: automating any other external Bash command that a candidate password can be appended to (that obeys the normal return code convention).
67
+ - Secondly candidate passwords can be sent to stdout, from where they can be piped to a
68
+ user's own external program or code (all the normal output from fiddlesticks goes to stderr).
69
+ - Thirdly, if py7zr is also installed, fiddlesticks can use it to test passwords for 7z archived
70
+ entirely within Python.
71
+
72
+ ### Other Notes
73
+ - Fiddlesticks cannot recover passwords for online accounts. Online password entry attempts
74
+ should be rate limited. Cracking is only possible locally if the website owner shares the
75
+ password hash with the user, in which case they can probably provide the rest of their
76
+ account data too.
77
+ - If Fiddlesticks fails to 'crack' or find a known password, this should not be taken as
78
+ proof of the password's strength. It won't ever be possible to think everything, and
79
+ we certainly don't wish users to draw a false sense of security from Fiddlesticks.
80
+
81
+ ## Alternatives
82
+ - https://github.com/philsmd/7z2hashcat
83
+ - https://en.wikipedia.org/wiki/Dictionary_attack#Dictionary_attack_software
84
+
85
+ [^0] Truly random passwords are difficult for humans to remember (without writing them down or saving them).
86
+ At the very least, real world adversaries (posessing a stolen file or password hash) are likely to first attempt a [dictionary attack](https://en.wikipedia.org/wiki/Dictionary_attack#Dictionary_attack_software)
@@ -0,0 +1,6 @@
1
+ fiddlesticks.py,sha256=iNh7YjmQKa1sqr29l67QkolCwJQuB3YlwrhPgBXRkbI,13110
2
+ fiddlesticks-0.0.0.dist-info/METADATA,sha256=GdXXTkXvCyYyKbdVOiukdyo5zrM5yrmUWUh7D5suVng,5245
3
+ fiddlesticks-0.0.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
4
+ fiddlesticks-0.0.0.dist-info/entry_points.txt,sha256=ooNwBgge2DVOiW8b8_l6DTJsecsOhHo6abQR6_hE12I,50
5
+ fiddlesticks-0.0.0.dist-info/licenses/LICENSE,sha256=Kb1fzpFcCzGbxK1-XIK9I9dH5SGGQWZHHe_wocdEOzE,1076
6
+ fiddlesticks-0.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ fiddlesticks = fiddlesticks:cli
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2026 James Parrott
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
4
+ this software and associated documentation files (the "Software"), to deal in
5
+ the Software without restriction, including without limitation the rights to
6
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
7
+ of the Software, and to permit persons to whom the Software is furnished to do
8
+ so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
fiddlesticks.py ADDED
@@ -0,0 +1,472 @@
1
+ # /// script
2
+ # requires-python = ">=3.12"
3
+ # dependencies = []
4
+ # optional_dependencies = [
5
+ # py7zr = ["py7zr",],
6
+ # ]
7
+ # ///
8
+
9
+ __version__ = "0.0.0"
10
+
11
+ import argparse
12
+ import atexit
13
+ import collections
14
+ import functools
15
+ import getpass
16
+ import io
17
+ import itertools
18
+ from pathlib import Path
19
+ import string
20
+ import subprocess
21
+ import sys
22
+ import tempfile
23
+ import textwrap
24
+ import time
25
+ from typing import Iterator, Iterable, Collection, Hashable, Callable
26
+ import warnings
27
+
28
+
29
+ TMP_DIR = Path(tempfile.gettempdir()) / "fiddlesticks"
30
+ TMP_DIR.mkdir(exist_ok=True)
31
+
32
+ SHIFT_MAP = {
33
+ '1': '!',
34
+ '2': '@"',
35
+ '3': '#£',
36
+ '4': '$',
37
+ '5': '%',
38
+ '6': '^',
39
+ '7': '&',
40
+ '8': '*',
41
+ '9': '(',
42
+ '0': ')',
43
+ '-': '_',
44
+ '=': '+',
45
+ '[': '{',
46
+ ']': '}',
47
+ '\\': '|',
48
+ ';': ':',
49
+ "'": '@',
50
+ ',': '<',
51
+ '.': '>',
52
+ '/': '?',
53
+ '#': '~',
54
+ '`': '~',
55
+ }
56
+
57
+ for c in string.ascii_lowercase:
58
+ SHIFT_MAP[c] = c.upper()
59
+
60
+ LEET_SPEAK = {
61
+ 'a': '4@',
62
+ 'e': '3',
63
+ 'i': '1',
64
+ 'o': '0',
65
+ 's': '5',
66
+ 't': '7',
67
+ 'b': '8',
68
+ 'g': '9',
69
+ }
70
+
71
+ for c, v in list(LEET_SPEAK.items()):
72
+ LEET_SPEAK[c.upper()] = v
73
+
74
+ def print_to_stderr(*objects, file=sys.stderr, **kwargs):
75
+ """ Simple wrapper to print to stderr instead of stdout,
76
+ for easy piping to stdout.
77
+ """
78
+ print(*objects, file=file, **kwargs)
79
+
80
+ def _calculate_total(lengths, M):
81
+ # dp[j] = sum of products for choosing j items
82
+ dp = [0] * (M + 1)
83
+ dp[0] = 1
84
+
85
+ for length in lengths:
86
+ for j in range(M, 0, -1):
87
+ dp[j] += dp[j-1] * length
88
+
89
+ return dp[M]
90
+
91
+
92
+ def _combine_maps[T: Hashable](
93
+ subs_maps: Iterable[dict[T,Iterable[T]]],
94
+ ) -> dict[T,list[T]]:
95
+
96
+ bi_map = collections.defaultdict(list)
97
+ for subs_map in subs_maps:
98
+ for k, v in subs_map.items():
99
+ bi_map[k].extend(v)
100
+ for c in v:
101
+ bi_map[c].append(k)
102
+ return bi_map
103
+
104
+
105
+ SHIFT_AND_LEET_BI_MAP = _combine_maps([SHIFT_MAP, LEET_SPEAK])
106
+
107
+
108
+ def candidate_passwords_from_alt_chars(
109
+ guess: str,
110
+ max_subs: int = 2,
111
+ alt_chars: list[list[str]] | None = None,
112
+ alt_char_map: dict[str, list[str]] = SHIFT_AND_LEET_BI_MAP,
113
+ ) -> tuple[int, Iterator[str]]:
114
+
115
+
116
+ alt_chars = alt_chars or [alt_char_map[c] for c in guess]
117
+
118
+ lengths = [len(chars) for chars in alt_chars]
119
+ total_num_candidates = sum(_calculate_total(lengths, M) for M in range(1, max_subs+1))
120
+
121
+ def generator():
122
+ yield guess
123
+
124
+ for num_subs in range(1, max_subs + 1):
125
+
126
+ for positions in itertools.combinations(range(len(guess)), num_subs):
127
+
128
+ pos_with_opts = [alt_chars[i] for i in positions if alt_chars[i]]
129
+
130
+ if len(pos_with_opts) != num_subs:
131
+ continue
132
+
133
+ choices_per_pos = [opts for opts in pos_with_opts]
134
+
135
+ for selected in itertools.product(*choices_per_pos):
136
+
137
+ candidate_password = list(guess)
138
+ for i, replacement in zip(positions, selected):
139
+ candidate_password[i] = replacement
140
+ yield ''.join(candidate_password)
141
+
142
+ return total_num_candidates, generator()
143
+
144
+
145
+ def make_py7zr_checker(archive: str, extract_to: str | None = None, **kwargs):
146
+ import py7zr
147
+ if extract_to is None:
148
+ extract_to = str(_make_new_tmp_sub_dir(archive))
149
+ stream = io.BytesIO(Path(archive).read_bytes())
150
+ def is_correct_password_for_7z_file(candidate: str, ) -> bool:
151
+ stream.seek(0)
152
+ try:
153
+ f = py7zr.SevenZipFile(stream, 'r', password=candidate)
154
+ f.extractall(path=extract_to)
155
+ except (py7zr.exceptions.PasswordRequired, py7zr.exceptions.Bad7zFile):
156
+ return False
157
+ else:
158
+ f.close()
159
+ return True
160
+ return is_correct_password_for_7z_file
161
+
162
+
163
+ def make_subprocess_checker(*args: str, **kwargs):
164
+ *rest, last = args
165
+ def is_correct_password(candidate: str) -> bool:
166
+ result = subprocess.run([*rest, f"{last}{candidate}"], capture_output=True)
167
+ return result.returncode==0
168
+ return is_correct_password
169
+
170
+ def _make_new_tmp_sub_dir(file: str) -> Path:
171
+ i = -1
172
+ suffix = ""
173
+ while (p := TMP_DIR / f"extracted{suffix}").is_dir():
174
+ i += 1
175
+ suffix = f"_{i}"
176
+ p.mkdir()
177
+ print_to_stderr(f"If {file} is unzipped successfully, contents will be in: {p}")
178
+ return p
179
+
180
+ class ProgramNotFound(Exception): pass
181
+
182
+ def make_7zip_checker(file: str, extract_to: str | None = None, **kwargs):
183
+ args = ["7z", "--help"]
184
+ result =subprocess.run(args, capture_output=True)
185
+ if result.returncode != 0:
186
+ raise ProgramNotFound(f"7zip. Args: {args}")
187
+
188
+ if extract_to is None:
189
+ extract_to = str(_make_new_tmp_sub_dir(file))
190
+
191
+ return make_subprocess_checker("7z","x", f"-o{extract_to}", file, "-p")
192
+
193
+ def make_password_candidate_piper(**kwargs):
194
+ def piper(password: str):
195
+ print(password, file=sys.stdout)
196
+ return False
197
+ return piper
198
+
199
+ def make_persistent_7zip_checker(file: str, extract_to: str | None = None, **kwargs):
200
+ if extract_to is None:
201
+ extract_to = str(_make_new_tmp_sub_dir(file))
202
+
203
+ cmd = textwrap.dedent(f"""\
204
+ while read -r line; do
205
+
206
+ # Silently run command, only check exit code
207
+ 7z x -o{extract_to} {file} -p"$line" > /dev/null 2>&1
208
+ if [ $? -eq 0 ]; then
209
+ echo "Success! :)"
210
+ break
211
+ fi
212
+ echo "Nope :("
213
+ done
214
+ """)
215
+
216
+ # Launch a single persistent Bash process reading from stdin line-by-line
217
+ proc = subprocess.Popen(
218
+ ["bash", "-c", cmd],
219
+ stdin=subprocess.PIPE,
220
+ stdout=subprocess.PIPE,
221
+ text=True,
222
+ bufsize=1 # Line buffered
223
+ )
224
+
225
+ def checker(candidate: str) -> bool:
226
+ # Send data down the pipe
227
+ proc.stdin.write(f"{candidate}\n")
228
+ proc.stdin.flush()
229
+
230
+ # Read the response back
231
+ response = proc.stdout.readline().strip()
232
+ return "Success" in response
233
+
234
+ @atexit.register
235
+ def cleanup():
236
+ proc.stdin.close()
237
+ proc.wait()
238
+
239
+ return checker
240
+
241
+
242
+ def test_passwords_sequentially(
243
+ candidates: Iterable[str],
244
+ test_func: Callable[[str], bool],
245
+ verbosity: int = 0,
246
+ update_every: int | None = None,
247
+ total: int | None = None,
248
+ print_passwords: bool = False,
249
+ **kwargs,
250
+ ) -> tuple[str, int] | None:
251
+
252
+ out_of_total = "" if total is None else f"/{total}"
253
+
254
+ if update_every is None:
255
+ update_every = max(1, total // 300)
256
+ if verbosity >= 1:
257
+ update_every = min(update_every, 1000)
258
+
259
+ for i, candidate in enumerate(candidates, start=1):
260
+ if test_func(candidate):
261
+ return candidate, i
262
+
263
+ if i % update_every:
264
+ continue
265
+ if verbosity == 0:
266
+ print_to_stderr(".", end="", flush=True)
267
+ elif verbosity >= 2 and print_passwords:
268
+ print_to_stderr(f"{i}{out_of_total}) tried: {candidate}", flush=True)
269
+ else:
270
+ print_to_stderr(f"{i}{out_of_total}", flush=True)
271
+
272
+ return None
273
+
274
+
275
+ default_password_protected_file_checker_factories = {
276
+ ".7z" : make_7zip_checker,
277
+ }
278
+
279
+
280
+ def default_checker_factory(*args: str, **kwargs):
281
+ if not args:
282
+ raise ValueError(
283
+ "Default checker requires arg(s) to define how to test the passwords"
284
+ )
285
+
286
+ path = Path(args[0])
287
+
288
+ if path.is_file():
289
+ return default_password_protected_file_checker_factories[path.suffix.lower()](*args)
290
+
291
+ return make_subprocess_checker(*args)
292
+
293
+
294
+ def try_find_password_sequentially(
295
+ extras: list[str],
296
+ password_guess: str | None = None,
297
+ max_subs: int = 2,
298
+ checker_factory: Callable[[str], Callable[[str], bool]] = make_subprocess_checker,
299
+ extract_to: str | None = None,
300
+ password_generator: Callable[[str, int], tuple[int | None, Iterator[str]]] = candidate_passwords_from_alt_chars,
301
+ alt_char_map: dict[str, list[str]] = SHIFT_AND_LEET_BI_MAP,
302
+ **kwargs,
303
+ ) -> tuple[str, int] | None:
304
+
305
+ checker = checker_factory(*extras, extract_to=extract_to)
306
+ if password_guess is None:
307
+ password_guess = getpass.getpass("Input password guess: ")
308
+ total, candidates = password_generator(guess=password_guess, max_subs=max_subs, alt_char_map=alt_char_map)
309
+ return test_passwords_sequentially(candidates, checker, total=total, **kwargs)
310
+
311
+
312
+
313
+ parser = argparse.ArgumentParser()
314
+ parser.suggest_on_error = True
315
+ parser.add_argument(
316
+ "--max-subs",
317
+ '-N',
318
+ type=int,
319
+ default=2,
320
+ help=("The maximum number of character substitutions "
321
+ "that will be applied to the guess"
322
+ ),
323
+ )
324
+ parser.add_argument('--verbosity', '-v', action='count', default=0)
325
+ parser.add_argument(
326
+ '--output-file',
327
+ '-o',
328
+ type=str,
329
+ default="",
330
+ help="File to write found passwords to.",
331
+ )
332
+ parser.add_argument(
333
+ '--print-passwords',
334
+ '-P',
335
+ action="store_true",
336
+ help=("Set this option to print passwords to stderr. "
337
+ "By default, neither found passwords (nor candidates if -vv) are printed. "
338
+ "E.g. if extracting a password-protected archive as a side-effect is sufficient "
339
+ "(and you intend to re-encrypt it with a different password anyway)."
340
+ ),
341
+ )
342
+ parser.add_argument("--password-guess", "-p", default=None, help=(
343
+ "WARNING! If given on the command line, its value may be saved by your shell "
344
+ "and e.g. appear in the Bash history file. Otherwise, you will be prompted to "
345
+ " securely enter this before the search can begin. "
346
+ )
347
+ )
348
+ parser.add_argument(
349
+ "--extract-to",
350
+ "-x",
351
+ default=None,
352
+ help=("The dir to try to extract archives in "
353
+ "(if using an external program as the password checker)"
354
+ ),
355
+ )
356
+ parser.add_argument(
357
+ "extras",
358
+ type=str,
359
+ nargs="*",
360
+ action="extend",
361
+ help=("Extra args to create the password checker with. "
362
+ "E.g. file to find password for, "
363
+ "or partial shell command, "
364
+ "to which the password guesses will be appended, "
365
+ "such as: 7z x archive.7z -p"
366
+ ),
367
+ )
368
+
369
+ parser.set_defaults(
370
+ checker_factory=default_checker_factory,
371
+ password_generator=candidate_passwords_from_alt_chars,
372
+ alt_char_map=SHIFT_AND_LEET_BI_MAP,
373
+ )
374
+
375
+ def add_mutex_group(
376
+ title: str | None = None,
377
+ description: str | None = None,
378
+ required: bool = False,
379
+ ):
380
+ arg_group = parser.add_argument_group(title=title, description=description)
381
+ mutex_arg_group = arg_group.add_mutually_exclusive_group(required=required)
382
+ return mutex_arg_group
383
+
384
+ checker_factories_group = add_mutex_group(
385
+ "Password checker",
386
+ "The method (if any) used to test candidate passwords."
387
+ )
388
+
389
+ def add_checker_factory_arg(name, checker_factory, help: str | None = None):
390
+ checker_factories_group.add_argument(
391
+ name,
392
+ dest="checker_factory",
393
+ action="store_const",
394
+ const=checker_factory,
395
+ help=help,
396
+ )
397
+ add_checker_factory_arg("--7zip", make_7zip_checker)
398
+ add_checker_factory_arg("--7zip-persistent", make_persistent_7zip_checker)
399
+ add_checker_factory_arg("--shell", make_subprocess_checker)
400
+ add_checker_factory_arg("--py7zr", make_py7zr_checker)
401
+ add_checker_factory_arg(
402
+ "--pipe",
403
+ make_password_candidate_piper,
404
+ help=("Print all password candidates to stdout, "
405
+ "e.g. to pipe them to an external password checking program. "
406
+ "Overrides --print-passwords. "
407
+ ),
408
+ )
409
+
410
+ alt_char_map_group = add_mutex_group(
411
+ "Character map",
412
+ ("The mapping for alternative characters, "
413
+ "to be used to generate candidate passwords from. "
414
+ )
415
+ )
416
+
417
+ def add_alt_char_map_arg(name, alt_char_map, help: str | None = None):
418
+ alt_char_map_group.add_argument(
419
+ name,
420
+ dest="alt_char_map",
421
+ action="store_const",
422
+ const=alt_char_map,
423
+ help=help,
424
+ )
425
+ add_alt_char_map_arg("--shift_and_leet", SHIFT_AND_LEET_BI_MAP)
426
+
427
+
428
+
429
+
430
+ def cli(args: list[str] = sys.argv[1:]) -> int:
431
+
432
+ if not args:
433
+ parser.print_help()
434
+ return 0
435
+
436
+ namespace = parser.parse_args()
437
+
438
+ if namespace.password_guess is not None:
439
+ warnings.warn(
440
+ "Password guess given on command line may be stored in history. "
441
+ "After this program ends, you may wish to delete the latest entry, "
442
+ "e.g. by running: history -d $(history 1 | awk '{print $1}')"
443
+ )
444
+
445
+ kwargs = vars(namespace).copy()
446
+
447
+ output_file = kwargs.pop("output_file")
448
+
449
+ t0 = time.time()
450
+
451
+ result = try_find_password_sequentially(**kwargs)
452
+
453
+ t1 = time.time()
454
+
455
+ if result is None:
456
+ print_to_stderr(f"\n\nCould not find password. Try a different guess, or increasing max substitutions (-N) ? ")
457
+ return 1
458
+
459
+
460
+ password, i = result
461
+ msg = f"\n Found password (guess number: {i}) in {t1-t0:.3f} seconds"
462
+ print_to_stderr(msg, end="")
463
+
464
+ print_to_stderr(f" {password=}" if namespace.print_passwords else "")
465
+
466
+ if output_file:
467
+ with open(output_file, "at") as f:
468
+ f.write(f"{msg}, {password=}")
469
+ return 0
470
+
471
+ if __name__ == '__main__':
472
+ cli()