From 1192d15758af05527e1c8c74e82f8f39f0585b0e Mon Sep 17 00:00:00 2001 From: maxpat78 Date: Tue, 15 Oct 2024 14:31:19 +0200 Subject: [PATCH] Re-packaged - code splitted and reorganized - bug fixed in mkdir -prepared for publishing with PyPI --- README.md | 14 +- mytest.bat | 15 +- .../4096words_en.txt | 0 pycryptomator/__init__.py | 3 + pycryptomator/__main__.py | 87 +++++++ pycryptomator/cmshell.py | 141 ++++++++++ .../cryptomator.py | 240 +----------------- pyproject.toml | 37 +++ 8 files changed, 296 insertions(+), 241 deletions(-) rename 4096words_en.txt => pycryptomator/4096words_en.txt (100%) create mode 100644 pycryptomator/__init__.py create mode 100644 pycryptomator/__main__.py create mode 100644 pycryptomator/cmshell.py rename cryptomator.py => pycryptomator/cryptomator.py (81%) create mode 100644 pyproject.toml diff --git a/README.md b/README.md index 13912f1..1eb1093 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # pycryptomator -A simple Python 3 script to access a Cryptomator V8 vault and carry on some useful operations. +A Python 3 package to access a Cryptomator V8 vault and carry on some useful operations. ``` usage: cryptomator.py [-h] [--init] [--print-keys [{a85,b64,words}]] [--master-keys PRIMARY_KEY HMAC_KEY] @@ -25,11 +25,17 @@ options: --change-password Change the password required to open the vault ``` -Passing a couple options, you can show you master keys or recover them in case configuration files are corrupted: +Passing a couple options, you can show you master keys or recover them in case +configuration files are corrupted: -`--print-keys` shows the decrypted primary and hmac master key in ASCII85 or BASE64 form, or as a list of English words like Cryptomator itself, to annotate them in a safe place for recovering purposes. +`--print-keys` shows the decrypted primary and hmac master key in ASCII85 +or BASE64 form, or as a list of English words like Cryptomator itself, to +annotate them in a safe place for recovering purposes. -`--master-keys` grants access to the vault even in case of lost configuration files `vault.cryptomator` and/or `masterkey.cryptomator`, provided the master keys as ASCII85 or BASE64 strings; `- -` can be used to read the words list from standard input. +`--master-keys` grants access to the vault even in case of lost configuration +files `vault.cryptomator` and/or `masterkey.cryptomator`, provided the master +keys as ASCII85 or BASE64 strings; `- -` can be used to read the words list +from standard input. After the `vault_name`, you can specify some useful operations like: diff --git a/mytest.bat b/mytest.bat index 57a7717..9eb836f 100644 --- a/mytest.bat +++ b/mytest.bat @@ -4,7 +4,7 @@ rd /s /q mytest >nul md mytest >nul SET DNAME="/Nome di directory lungo, anzi, lunghissimo, ossia dal nome veramente lunghissimissimo e tale da venire codificato con oltre 255 caratteri dal codec Base64 di Cryptomator in modo da generare un doppio nome di file cifrato con hash" SET FNAME="/Nome di file lungo, anzi, lunghissimo, ossia dal nome veramente lunghissimissimo e tale da venire codificato con oltre 255 caratteri dal codec Base64 di Cryptomator in modo da generare un doppio nome di file cifrato con hash.txt" -SET P=cryptomator.py --password=pippo +SET P=py -m pycryptomator --password=pippo echo ++ Testing vault initialization %P% --init mytest echo ++ Testing master keys printing @@ -23,9 +23,18 @@ echo ++ Testing decryption to STDOUT %P% mytest decrypt /link_subdir_mytest.bat - echo ++ Testing alias %P% mytest alias /link_subdir_mytest.bat +echo ++ Testing rename +%P% mytest mv /link_subdir_mytest.bat /same_link.bat echo ++ Testing removing files and directory %P% mytest rm /link_subdir_mytest.bat -%P% mytest rmdir %DNAME% +%P% mytest rm /same_link.bat +%P% mytest rm %DNAME% %P% mytest rm %DNAME%/mytest.bat -%P% mytest rmdir %DNAME% +%P% mytest rm %DNAME% %P% mytest ls +exit /b +%P% mytest encrypt cryptolib-develop /cryptolib-develop +%P% mytest ls +%P% mytest decrypt -f /cryptolib-develop T +%P% mytest backup dirids.zip +%P% mytest rm -f /cryptolib-develop diff --git a/4096words_en.txt b/pycryptomator/4096words_en.txt similarity index 100% rename from 4096words_en.txt rename to pycryptomator/4096words_en.txt diff --git a/pycryptomator/__init__.py b/pycryptomator/__init__.py new file mode 100644 index 0000000..d1a9212 --- /dev/null +++ b/pycryptomator/__init__.py @@ -0,0 +1,3 @@ +COPYRIGHT = '''Copyright (C)2024, by maxpat78.''' +__version__ = '1.0' +__all__ = ["Vault", "init_vault", "backupDirIds"] diff --git a/pycryptomator/__main__.py b/pycryptomator/__main__.py new file mode 100644 index 0000000..c42a0b7 --- /dev/null +++ b/pycryptomator/__main__.py @@ -0,0 +1,87 @@ +import locale, sys, argparse, shlex +from os.path import * +from .cryptomator import * +from .cmshell import CMShell + +""" + + MIT License + + Copyright (c) 2024 maxpat78 + +""" + +locale.setlocale(locale.LC_ALL, '') + +parser = argparse.ArgumentParser(prog='pycryptomator', description="Access to a Cryptomator V8 vault") +parser.add_argument('--init', action="store_true", help="Initialize a new vault in an empty directory") +parser.add_argument('--print-keys', help="Print the raw master keys as a list of English words for Cryptomator (default), in ASCII85 (a85) or BASE64 (b64) format", type=str, choices=['a85','b64','words'], const='words', nargs='?') +parser.add_argument('--master-keys', nargs=2, metavar=('PRIMARY_KEY', 'HMAC_KEY'), help="Primary and HMAC master keys in ASCII85 or BASE64 format, or - - to read a words list from standard input") +parser.add_argument('--password', help="Password to unlock master keys stored in config file") +parser.add_argument('--change-password', help="Change the password required to open the vault", action="store_true") +parser.add_argument('vault_name', help="Location of the existing Cryptomator V8 vault to use") +args, extras = parser.parse_known_args() + +if args.init: + init_vault(args.vault_name, args.password) + sys.exit(0) + +if not args.password and not args.master_keys: + args.password = getpass.getpass() + +if args.master_keys: + if args.master_keys[0] == '-': + words = input('Words list: ') + words = words.split() + if len(words) != 44: raise BaseException('Not enough words') + we = Wordsencoder(join(dirname(sys.argv[0]), '4096words_en.txt')) + b = we.words2bytes(words) + we.validate(b) + pk = b[:32] + hk = b[32:64] + print() + else: + def tryDecode(s): + e = 0 + d = b'' + try: d = base64.a85decode(s) + except: pass + if len(d) == 32: return d + try: d = base64.urlsafe_b64decode(s) + except: pass + if len(d) == 32: return d + raise BaseException('Could not decode master key "%s"'%s) + pk = tryDecode(args.master_keys[0]) + hk = tryDecode(args.master_keys[1]) + v = Vault(args.vault_name, pk=pk, hk=hk) +else: + v = Vault(args.vault_name, args.password) + +if args.print_keys: + print('\n * * * WARNING !!! * * *\n') + print('KEEP THESE KEYS TOP SECRET!\nFor recovering purposes only.\n') + + if args.print_keys == 'a85': + encoder = base64.a85encode + elif args.print_keys == 'b64': + encoder = base64.urlsafe_b64encode + else: + # initialize the words encoder with a dictionary in the same directory + # it contains 4096 English words + we = Wordsencoder(join(dirname(sys.argv[0]), '4096words_en.txt')) + words = we.bytes2words(we.blob(v.pk, v.hk)) + print(' '.join(words)) + sys.exit(0) + print('Primary master key :', encoder(v.pk).decode()) + print('HMAC master key :', encoder(v.hk).decode()) + sys.exit(0) + +if args.change_password: + v.change_password() + sys.exit(0) + +if not extras: + CMShell(v).cmdloop() # start a shell with open vault +else: + # We must re-quote args, shlex should suffice + CMShell(v).onecmd(shlex.join(extras)) # execute single command via shell diff --git a/pycryptomator/cmshell.py b/pycryptomator/cmshell.py new file mode 100644 index 0000000..89af56c --- /dev/null +++ b/pycryptomator/cmshell.py @@ -0,0 +1,141 @@ +import cmd, sys, shlex +from os.path import * +from .cryptomator import * + +class CMShell(cmd.Cmd): + intro = 'PyCryptomator Shell. Type help or ? to list all available commands.' + prompt = 'PCM:> ' + vault = None + + def __init__ (p, vault): + p.vault = vault + super(CMShell, p).__init__() + + def preloop(p): + p.prompt = '%s:> ' % p.vault.base + + def do_debug(p, arg): + pass + + def do_quit(p, arg): + 'Quit the PyCryptomator Shell' + sys.exit(0) + + def do_alias(p, arg): + argl = shlex.split(arg) + if not argl: + print('use: alias ') + return + i = p.vault.getInfo(argl[0]) + print(i.realPathName) + + def do_backup(p, arg): + 'Backup all the dir.c9r with their tree structure in a ZIP archive' + argl = shlex.split(arg) + if not argl: + print('use: backup ') + return + backupDirIds(p.vault.base, argl[0]) + + def do_decrypt(p, arg): + 'Decrypt files or directories from the vault' + argl = shlex.split(arg) + force = '-f' in argl + if force: argl.remove('-f') + if not argl or argl[0] == '-h' or len(argl) != 2: + print('use: decrypt [-f] ') + print('use: decrypt -') + return + try: + is_dir = p.vault.getInfo(argl[0]).isDir + if is_dir: p.vault.decryptDir(argl[0], argl[1], force) + else: + p.vault.decryptFile(argl[0], argl[1], force) + if argl[1] == '-': print() + except: + print(sys.exception()) + + def do_encrypt(p, arg): + 'Encrypt files or directories into the vault' + argl = shlex.split(arg) + if not argl or argl[0] == '-h' or len(argl) != 2: + print('use: encrypt ') + return + try: + if isdir(argl[0]): + p.vault.encryptDir(argl[0], argl[1]) + else: + p.vault.encryptFile(argl[0], argl[1]) + except: + print(sys.exception()) + + def do_ls(p, arg): + 'List files and directories' + argl = shlex.split(arg) + recursive = '-r' in argl + if recursive: argl.remove('-r') + if not argl: argl += ['/'] # implicit argument + if argl[0] == '-h': + print('use: ls [-r] [...]') + return + for it in argl: + try: + p.vault.ls(it, recursive) + except: + pass + + def do_ln(p, arg): + 'Make a symbolic link to a file or directory' + argl = shlex.split(arg) + if len(argl) != 2: + print('use: ln ') + return + try: + p.vault.ln(argl[0], argl[1]) + except: + print(sys.exception()) + + def do_mkdir(p, arg): + 'Make a directory or directory tree' + argl = shlex.split(arg) + if not argl or argl[0] == '-h': + print('use: mkdir [...]') + return + for it in argl: + try: + p.vault.mkdir(it) + except: + print(sys.exception()) + + def do_mv(p, arg): + 'Move or rename files or directories' + argl = shlex.split(arg) + if len(argl) < 2 or argl[0] == '-h': + print('please use: mv [...] ') + return + for it in argl[:-1]: + p.vault.mv(it, argl[-1]) + + def do_rm(p, arg): + 'Remove files and directories' + argl = shlex.split(arg) + force = '-f' in argl + if force: argl.remove('-f') + if not argl or argl[0] == '-h': + print('use: rm [...]') + return + for it in argl: + if it == '/': + print("Won't erase root directory.") + return + try: + i = p.vault.getInfo(it) + if not i.isDir: + p.vault.remove(it) # del file + continue + if force: + p.vault.rmtree(it) # del dir, even if nonempty + continue + p.vault.rmdir(it) # del empty dir + except: + print(sys.exception()) diff --git a/cryptomator.py b/pycryptomator/cryptomator.py similarity index 81% rename from cryptomator.py rename to pycryptomator/cryptomator.py index fb1ec98..cd1af08 100644 --- a/cryptomator.py +++ b/pycryptomator/cryptomator.py @@ -1,5 +1,3 @@ -#!/usr/bin/python3 - """ MIT License @@ -11,9 +9,9 @@ # Requires pycryptodome(x) # EOL is to make bash happy with #! -import argparse, getpass, hashlib, struct, base64 -import json, sys, io, os, operator, re, shlex -import time, zipfile, locale, zlib, uuid, shutil, cmd +import getpass, hashlib, struct, base64 +import json, sys, io, os, operator +import time, zipfile, locale, zlib, uuid, shutil from os.path import * try: @@ -276,7 +274,7 @@ def encryptFile(p, src, virtualpath, force=False): raise BaseException('Source file does not exist: '+src) f = open(src, 'rb') if not basename(virtualpath).endswith('dirid.c9r'): - rp = p.makefile(virtualpath) + rp = p.create(virtualpath) else: rp = virtualpath out = open(rp,'wb') @@ -398,7 +396,7 @@ def mkdir(p, virtualpath): if (virtualpath[0] != '/'): raise BaseException('the vault path to the directory to create must be absolute!') while 1: - x = v.getInfo(virtualpath) + x = p.getInfo(virtualpath) if x.exists: break # make the encrypted directory os.mkdir(x.realPathName) @@ -414,7 +412,7 @@ def mkdir(p, virtualpath): if x.longName: open(x.nameC9,'wb').write(x.longName) return x.realDir - def makefile(p, virtualpath): + def create(p, virtualpath): "Create an empty file and, eventually, its intermediate directories" p.mkdir(dirname(virtualpath)) # ensure base path exists x = p.getInfo(virtualpath) @@ -851,229 +849,3 @@ def crc(p, s): if len(s) != 64: raise BaseException('Decoded master keys must be 512 bits long!') crc = zlib.crc32(s) return crc.to_bytes(4,'little')[:2] - - -class CMShell(cmd.Cmd): - intro = 'PyCryptomator Shell. Type help or ? to list all available commands.' - prompt = 'PCM:> ' - - def preloop(p): - p.prompt = '%s:> ' % v.base - - #~ def precmd(p, cmdline): - #~ 'Pre-process cmdline before passing it to a command' - #~ return cmdline - - def do_debug(p, arg): - pass - - def do_quit(p, arg): - 'Quit the PyCryptomator Shell' - sys.exit(0) - - def do_backup(p, arg): - 'Backup all the dir.c9r with their tree structure in a ZIP archive' - argl = shlex.split(arg) - if not argl: - print('use: backup ') - return - backupDirIds(v.base, argl[0]) - - def do_decrypt(p, arg): - 'Decrypt files or directories from the vault' - argl = shlex.split(arg) - force = '-f' in argl - if force: argl.remove('-f') - if not argl or argl[0] == '-h' or len(argl) != 2: - print('use: decrypt [-f] ') - print('use: decrypt -') - return - try: - is_dir = v.getInfo(argl[0]).isDir - if is_dir: v.decryptDir(argl[0], argl[1], force) - else: - v.decryptFile(argl[0], argl[1], force) - if argl[1] == '-': print() - except: - print(sys.exception()) - - def do_encrypt(p, arg): - 'Encrypt files or directories into the vault' - argl = shlex.split(arg) - if not argl or argl[0] == '-h' or len(argl) != 2: - print('use: encrypt ') - return - try: - if isdir(argl[0]): - v.encryptDir(argl[0], argl[1]) - else: - v.encryptFile(argl[0], argl[1]) - except: - print(sys.exception()) - - def do_ls(p, arg): - 'List files and directories' - argl = shlex.split(arg) - recursive = '-r' in argl - if recursive: argl.remove('-r') - if not argl: argl += ['/'] # implicit argument - if argl[0] == '-h': - print('use: ls [-r] [...]') - return - for it in argl: - try: - v.ls(it, recursive) - except: - pass - - def do_ln(p, arg): - 'Make a symbolic link to a file or directory' - argl = shlex.split(arg) - if len(argl) != 2: - print('use: ln ') - return - try: - v.ln(argl[0], argl[1]) - except: - print(sys.exception()) - - def do_mkdir(p, arg): - 'Make a directory or directory tree' - argl = shlex.split(arg) - if not argl or argl[0] == '-h': - print('use: mkdir [...]') - return - for it in argl: - try: - v.mkdir(it) - except: - print(sys.exception()) - - def do_mv(p, arg): - 'Move or rename files or directories' - argl = shlex.split(arg) - if len(argl) < 2 or argl[0] == '-h': - print('please use: mv [...] ') - return - for it in argl[:-1]: - v.mv(it, argl[-1]) - - def do_rm(p, arg): - 'Remove files and directories' - argl = shlex.split(arg) - force = '-f' in argl - if force: argl.remove('-f') - if not argl or argl[0] == '-h': - print('use: rm [...]') - return - for it in argl: - if it == '/': - print("Won't erase root directory.") - return - try: - i = v.getInfo(it) - if not i.isDir: - v.remove(it) # del file - continue - if force: - v.rmtree(it) # del dir, even if nonempty - continue - v.rmdir(it) # del empty dir - except: - print(sys.exception()) - - -def split_arg_string(s): - rv = [] - for match in re.finditer(r"('([^'\\]*(?:\\.[^'\\]*)*)'" - r'|"([^"\\]*(?:\\.[^"\\]*)*)"' - r'|\S+)\s*', s, re.S): - arg = match.group().strip() - if arg[:1] == arg[-1:] and arg[:1] in '"\'': - arg = arg[1:-1].encode('ascii', 'backslashreplace').decode('unicode-escape') - try: - arg = type(s)(arg) - except UnicodeError: - pass - rv.append(arg) - return rv - - - -if __name__ == '__main__': - locale.setlocale(locale.LC_ALL, '') - - parser = argparse.ArgumentParser(description="Access to a Cryptomator V8 vault") - parser.add_argument('--init', action="store_true", help="Initialize a new vault in an empty directory") - parser.add_argument('--print-keys', help="Print the raw master keys as a list of English words for Cryptomator (default), in ASCII85 (a85) or BASE64 (b64) format", type=str, choices=['a85','b64','words'], const='words', nargs='?') - parser.add_argument('--master-keys', nargs=2, metavar=('PRIMARY_KEY', 'HMAC_KEY'), help="Primary and HMAC master keys in ASCII85 or BASE64 format, or - - to read a words list from standard input") - parser.add_argument('--password', help="Password to unlock master keys stored in config file") - parser.add_argument('--change-password', help="Change the password required to open the vault", action="store_true") - parser.add_argument('vault_name', help="Location of the existing Cryptomator V8 vault to use") - args, extras = parser.parse_known_args() - - if args.init: - init_vault(args.vault_name, args.password) - sys.exit(0) - - if not args.password and not args.master_keys: - args.password = getpass.getpass() - - if args.master_keys: - if args.master_keys[0] == '-': - words = input('Words list: ') - words = words.split() - if len(words) != 44: raise BaseException('Not enough words') - we = Wordsencoder(join(dirname(sys.argv[0]), '4096words_en.txt')) - b = we.words2bytes(words) - we.validate(b) - pk = b[:32] - hk = b[32:64] - print() - else: - def tryDecode(s): - e = 0 - d = b'' - try: d = base64.a85decode(s) - except: pass - if len(d) == 32: return d - try: d = base64.urlsafe_b64decode(s) - except: pass - if len(d) == 32: return d - raise BaseException('Could not decode master key "%s"'%s) - pk = tryDecode(args.master_keys[0]) - hk = tryDecode(args.master_keys[1]) - v = Vault(args.vault_name, pk=pk, hk=hk) - else: - v = Vault(args.vault_name, args.password) - - if args.print_keys: - print('\n * * * WARNING !!! * * *\n') - print('KEEP THESE KEYS TOP SECRET!\nFor recovering purposes only.\n') - - if args.print_keys == 'a85': - encoder = base64.a85encode - elif args.print_keys == 'b64': - encoder = base64.urlsafe_b64encode - else: - # initialize the words encoder with a dictionary in the same directory - # it contains 4096 English words - we = Wordsencoder(join(dirname(sys.argv[0]), '4096words_en.txt')) - words = we.bytes2words(we.blob(v.pk, v.hk)) - print(' '.join(words)) - sys.exit(0) - print('Primary master key :', encoder(v.pk).decode()) - print('HMAC master key :', encoder(v.hk).decode()) - sys.exit(0) - - if args.change_password: - v.change_password() - sys.exit(0) - - if not extras: - CMShell().cmdloop() # start a shell with open vault - else: - # We must re-quote args, shlex should suffice - CMShell().onecmd(shlex.join(extras)) # execute single command via shell - - sys.exit(0) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..39c6442 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,37 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "pycryptomator" +dynamic=["version"] +description = "Access a Cryptomator V8 vault with pure Python" +readme = "README.md" +requires-python = ">=3.0" +license = {file = "LICENSE"} +keywords = ["cryptomator", "python", "crypto", "encryption", "security", "privacy", "cryptography", "vault"] +authors = [ + {name = "maxpat78", email = "maxpat78@yahoo.it" } +] + +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3 :: Only", +] + +dependencies = [ + "pycryptodome>=3.21" +] + +[project.urls] +"Homepage" = "https://github.com/maxpat78/pycryptomator" +"Source" = "https://github.com/maxpat78/pycryptomator" + +[tool.setuptools] +packages = ["pycryptomator"] +package-data = {"pycryptomator" = ["*.txt"]} + +[tool.setuptools.dynamic] +version = {attr = "pycryptomator.__init__.__version__"}