From eae9db52e1051e95e9f546bf4073c198d06902cf Mon Sep 17 00:00:00 2001 From: maxpat78 Date: Tue, 1 Oct 2024 17:00:02 +0200 Subject: [PATCH] New enhancements - added an interactive shell to operate on open vault - shell is also invoked to launch single operations issued from command line - added mv command to move/rename items - makedirs command is replaced by mkdir --- README.md | 22 ++-- cryptomator.py | 319 +++++++++++++++++++++++++++++++++---------------- 2 files changed, 226 insertions(+), 115 deletions(-) diff --git a/README.md b/README.md index 9a78677..13912f1 100644 --- a/README.md +++ b/README.md @@ -25,26 +25,28 @@ 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: + +`--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. + + After the `vault_name`, you can specify some useful operations like: ``` ls list unecrypted vault contents (with size and time) +mkdir create a new directory/tree in the vault +mv move or rename files and directories +ln create a symbolic link +rm erase files or directories decrypt decrypt a file or directory from the vault's virtual filesystem into a given destination encrypt encrypt a file or directory -makedirs create a new directory/tree in the vault -ln create a symbolic link -rm erase a file or symbolic link -rmdir remove an empty directory -rmtree remove a full directory tree alias show the real pathname linked to a virtual one backup backup the Directory IDs (required to decrypt names) in a ZIP file ``` -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. - -`--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. +If no operation is specified, an interactive shell is launched on open vault. Functionality was tested in Windows 11 and Ubuntu 22.04 LTS Linux (under Windows WSL). diff --git a/cryptomator.py b/cryptomator.py index d582170..fb1ec98 100644 --- a/cryptomator.py +++ b/cryptomator.py @@ -12,8 +12,8 @@ # EOL is to make bash happy with #! import argparse, getpass, hashlib, struct, base64 -import json, sys, io, os, operator -import time, zipfile, locale, zlib, uuid, shutil +import json, sys, io, os, operator, re, shlex +import time, zipfile, locale, zlib, uuid, shutil, cmd from os.path import * try: @@ -32,20 +32,22 @@ def __init__ (p): p.longName = '' # store the encrypted long name, if any p.dirId = '' # directory id to crypt names inside the directory (or this file name, if it is a file) p.realPathName = '' # real (filesystem's) pathname derived crypting the virtual .pathname + # when making dirs: also, intermediate dir to create p.realDir = '' # real (filesystem's) contents directory associated to directory .pathname or containing file .pathname p.hasSym = '' # path to the symlink.c9r, if it is a symbolic link p.isDir = 0 # whether it is (or points to) a directory p.pointsTo = '' # destination of the symbolic link, if any - p.exists = 0 + p.exists = 0 # if it exists on disk def __str__(p): - #~ return ".pathname=%s .dirId=%s .realPathName=%s .realDir=%s .hasSym=%s .isDir=%d .pointsTo=%s .exists=%d" % (p.pathname,p.dirId,p.realPathName,p.realDir,p.hasSym,p.isDir,p.pointsTo,p.exists) + base = '<%s' % (('nonexistent ','')[p.exists]) if p.hasSym: - return ' "%s">' % (("File","Directory")[p.isDir], p.pathname, p.hasSym) + base += 'PathInfo.Symlink (%s) "%s" -> "%s"' % (("File","Directory")[p.isDir], p.pathname, p.pointsTo) elif p.isDir: - return '' % (p.pathname) + base += 'PathInfo.Directory "%s" (%s)' % (p.pathname, p.realDir) else: - return '' % (p.pathname) + base += 'PathInfo.File "%s"' % (p.pathname) + return base + " .realPathName=%s>" % (p.realPathName) @property def nameC9(p): @@ -290,7 +292,7 @@ def encryptFile(p, src, virtualpath, force=False): def encryptDir(p, src, virtualpath, force=False): if (virtualpath[0] != '/'): raise BaseException('the vault path must be absolute!') - real = p.makedirs(virtualpath) + real = p.mkdir(virtualpath) n=0 nn=0 total_bytes = 0 @@ -300,7 +302,7 @@ def encryptDir(p, src, virtualpath, force=False): for it in files: fn = join(root, it) dn = join(virtualpath, fn[len(src)+1:]) # target pathname - p.makedirs(dirname(dn)) + p.mkdir(dirname(dn)) print(dn) total_bytes += p.encryptFile(fn, dn, force) n += 1 @@ -391,34 +393,30 @@ def stat(p, virtualpath): x = p.getInfo(virtualpath) return os.stat(x.contentsC9) - def _mkdir(p, realpath): - "Initialize a new directory" - # make the encrypted directory - os.mkdir(realpath) - # assign a random directory id - dirId = str(uuid.uuid4()).encode() - open(join(realpath,'dir.c9r'),'wb').write(dirId) - # make the associated contents directory and store a backup copy of the dir id - hdid = p.hashDirId(dirId) - rp = join(p.base, 'd', hdid[:2], hdid[2:]) - os.makedirs(rp) - backup = join(rp, 'dirid.c9r') - p.encryptFile(io.BytesIO(dirId), backup) - - def makedirs(p, virtualpath): + def mkdir(p, virtualpath): "Create a new directory or tree in the vault" if (virtualpath[0] != '/'): raise BaseException('the vault path to the directory to create must be absolute!') while 1: x = v.getInfo(virtualpath) if x.exists: break - v._mkdir(x.realPathName) + # make the encrypted directory + os.mkdir(x.realPathName) + # assign a random directory id + dirId = str(uuid.uuid4()).encode() + open(join(x.realPathName,'dir.c9r'),'wb').write(dirId) + # make the associated contents directory and store a backup copy of the dir id + hdid = p.hashDirId(dirId) + rp = join(p.base, 'd', hdid[:2], hdid[2:]) + os.makedirs(rp) + backup = join(rp, 'dirid.c9r') + p.encryptFile(io.BytesIO(dirId), backup) if x.longName: open(x.nameC9,'wb').write(x.longName) return x.realDir def makefile(p, virtualpath): "Create an empty file and, eventually, its intermediate directories" - p.makedirs(dirname(virtualpath)) # ensure base path exists + p.mkdir(dirname(virtualpath)) # ensure base path exists x = p.getInfo(virtualpath) if x.longName: dn = dirname(x.nameC9) @@ -483,6 +481,7 @@ def rmdir(p, virtualpath): else: os.remove(x.dirC9) os.rmdir(x.realPathName) + del p.dirid_cache[x.dirC9] # delete from cache also def rmtree(p, virtualpath): "Delete a full virtual directory tree" @@ -508,7 +507,7 @@ def rmtree(p, virtualpath): dd += 1 # Finally, delete the empty base directory p.rmdir(virtualpath) - print ('rmtree: deleted %d files in %d directories' % (ff,dd)) + print ('rmtree: deleted %d files in %d directories in %s' % (ff,dd,virtualpath)) def ln(p, target, symlink): "Create a symbolic link" @@ -564,6 +563,43 @@ def _realsize(n): if recursive: print('\n Total files listed:\n%s bytes in %s files and %s directories.' % (_fmt_size(gtot_size), _fmt_size(gtot_files), _fmt_size(gtot_dirs))) + def mv(p, src, dst): + "Move or rename files and directories" + a = p.getInfo(src) + b = p.getInfo(dst) + if not a.exists: + print("Can't move nonexistent object %s"%src) + return + if a.realPathName == b.realPathName: + print("Can't move an object onto itself: %s"%src) + return + if b.exists: + if not b.isDir: + print("Can't move %s, target exists already"%dst) + return + c = p.getInfo(join(dst, basename(src))) + if c.exists: + if c.isDir and os.listdir(c.realDir): + print("Can't move, target directory \"%s\" not empty"%c.pathname) + return + elif not c.isDir: + print("Can't move \"%s\", target exists already"%c.pathname) + return + shutil.move(a.realPathName, c.realPathName) + if a.longName: + open(c.nameC9,'wb').write(c.longName) # update long name + return + if a.longName: + # long name dir (file) -> file + if not a.isDir: + shutil.move(a.contentsC9, b.realPathName) + os.remove(a.nameC9) + os.rmdir(a.realPathName) + return + else: + os.remove(a.nameC9) # remove long name + os.rename(a.realPathName, b.realPathName) # change the encrypted name + # os.walk by default does not follow dir links def walk(p, virtualpath): "Traverse the virtual file system like os.walk" @@ -817,6 +853,152 @@ def crc(p, 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, '') @@ -889,82 +1071,9 @@ def tryDecode(s): sys.exit(0) if not extras: - print('An operation must be specified among alias, backup, decrypt, encrypt, ln, ls, makedirs, rm, rmdir, rmtree.') - sys.exit(1) - - if extras[0] == 'alias': - if len(extras) == 1: - print('please use: alias ') - sys.exit(1) - x = v.getInfo(extras[1]) - #~ print('"%s" is the real pathname for %s' % (v.getFilePath(extras[1]), extras[1])) - print('"%s" is the real pathname for %s' % (x.nameC9, extras[1])) - elif extras[0] == 'backup': - if len(extras) == 1: - print('please use: backup ') - sys.exit(1) - backupDirIds(v.base, extras[1]) - print('done.') - elif extras[0] == 'ls': - recursive = '-r' in extras - if recursive: extras.remove('-r') - if len(extras) == 1: - #~ print('please use: ls [-r] [...]') - extras += ['/'] # implicit argument - for it in extras[1:]: - v.ls(it, recursive) - elif extras[0] == 'decrypt': # decrypt files or directories - force = '-f' in extras - if force: extras.remove('-f') - if len(extras) != 3: - print('please use: decrypt [-f] ') - sys.exit(1) - is_dir = v.getInfo(extras[1]).isDir - if is_dir: v.decryptDir(extras[1], extras[2], force) - else: - v.decryptFile(extras[1], extras[2], force) - if extras[2] == '-': print() - print('done.') - elif extras[0] == 'makedirs': - if len(extras) != 2: - print('please use: makedirs ') # intermediate directories get created - sys.exit(1) - v.makedirs(extras[1]) - elif extras[0] == 'ln': - if len(extras) != 3: - print('please use: ln ') - sys.exit(1) - v.ln(extras[1], extras[2]) - print('done.') - elif extras[0] == 'encrypt': # encrypt files or directories - if len(extras) != 3: - print('please use: encrypt ') - sys.exit(1) - if isdir(extras[1]): - v.encryptDir(extras[1], extras[2]) - else: - v.encryptFile(extras[1], extras[2]) - print('done.') - elif extras[0] == 'rm': - if len(extras) == 1: - print('please use: rm [ [ [