diff --git a/.gitattributes b/.gitattributes index 7c479b7..e69de29 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +0,0 @@ -sensitive_file filter=crypt diff=crypt merge=crypt diff --git a/CHANGELOG.md b/CHANGELOG.md index bbc9bb1..b95dad2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ The format is based on [Keep a Changelog][1], and this project adheres to [1]: https://keepachangelog.com/en/1.0.0/ [2]: https://semver.org/spec/v2.0.0.html + +## [3.0.0] - [Unreleased] + +### Added +- Add support for pbkdf2 +- Add support for user specified digest +- Add support for new configured salt method +- Add .transcrypt versioned directory +- Support for OpenSSL 3.x +- Add support for development editable install + + ## [Unreleased] ### Added diff --git a/contrib/bash/transcrypt b/contrib/bash/transcrypt index 07dafa0..eb9a17b 100644 --- a/contrib/bash/transcrypt +++ b/contrib/bash/transcrypt @@ -26,13 +26,27 @@ _transcrypt() { case "${prev}" in -c | --cipher) - local ciphers=$(openssl list-cipher-commands) + local ciphers=$(openssl list-cipher-commands || openssl list -cipher-commands &2>/dev/null) COMPREPLY=( $(compgen -W "${ciphers}" -- ${cur}) ) return 0 ;; -p | --password) return 0 ;; + -md | --digest) + local ciphers=$(openssl list-digest-commands || openssl list -digest-commands &2>/dev/null) + COMPREPLY=( $(compgen -W "${ciphers}" -- ${cur}) ) + return 0 + ;; + --use-pbkdf2) + return 0 + ;; + -sm | --salt-method) + return 0 + ;; + -cs | --config-salt) + return 0 + ;; -s | --show-raw) _files_and_dirs return 0 diff --git a/contrib/packaging/pacman/PKGBUILD b/contrib/packaging/pacman/PKGBUILD index beb4759..553eabf 100644 --- a/contrib/packaging/pacman/PKGBUILD +++ b/contrib/packaging/pacman/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Aaron Bull Schaefer pkgname=transcrypt -pkgver=2.1.0 +pkgver=3.0.0 pkgrel=1 pkgdesc='A script to configure transparent encryption of files within a Git repository' arch=('any') diff --git a/contrib/zsh/_transcrypt b/contrib/zsh/_transcrypt index 523df12..63daa0f 100644 --- a/contrib/zsh/_transcrypt +++ b/contrib/zsh/_transcrypt @@ -11,6 +11,10 @@ _transcrypt() { '(- 1 *)'{-v,--version}'[print version]' \ '(- 1 *)'{-h,--help}'[view help message]' \ '(-c --cipher -d --display -f --flush-credentials -u --uninstall)'{-c,--cipher=}'[specify encryption cipher]:cipher:->cipher' \ + '(-md --digest -d --display -f --flush-credentials -u --uninstall)'{-md,--digest=}'[specify encryption digest]:digest' \ + '(-sm --salt-method -d --display -f --flush-credentials -u --uninstall)'{-md,--digest=}'[specify salt-method]:salt-method' \ + '(-cs --config-salt -d --display -f --flush-credentials -u --uninstall)'{-md,--digest=}'[specify config-salt]:config-salt' \ + '(-pbkdf2 --use-pbkdf2 -d --display -f --flush-credentials -u --uninstall)'{-md,--digest=}'[specify use-pbkdf2]:use-pbkdf2' \ '(-p --password -d --display -f --flush-credentials -u --uninstall)'{-p,--password=}'[specify encryption password]:password:' \ '(-y --yes)'{-y,--yes}'[assume yes and accept defaults]' \ '(-d --display -p --password -c --cipher -r --rekey -u --uninstall)'{-d,--display}'[display current credentials]' \ diff --git a/docs/algorithm.rst b/docs/algorithm.rst new file mode 100644 index 0000000..a56b989 --- /dev/null +++ b/docs/algorithm.rst @@ -0,0 +1,318 @@ +The Transcrypt Algorithm +======================== + +The transcrypt algorithm makes use of the following components: + +* `git _` +* `bash _` +* `openssl _` + +The "clean" and "smudge" git filters implement the core functionality by +encrypting a sensitive file before committing it to the repo history, and +decrypting the file when a local copy of the file is checked out. + +* `filter.crypt.clean` - "transcrypt clean" + +* `filter.crypt.smudge` - "transcrypt smudge" + + +Transcrypt uses openssl for all underlying cryptographic operations. + +From git's perspective, is only tracks the encrypted ciphertext of each file. +Thus is it important that any encryption algorithm used must be deterministic, +otherwise changes in the ciphertext (e.g. due to randomized salt) will cause +git to think the file has changed when it hasn't. + + +Core Algorithms +=============== + +From a high level, lets assume we have a secure process to save / load a +desired configuration. + + +The Encryption Process +---------------------- + +A file is encrypted via the following procedure in the ``filter.crypt.clean`` filter. + +Given a sensitive file specified by ``filename`` + +1. Empty files are ignored + +2. A temporary file is created with the (typically plaintext) contents of ``filename``. + This file only contains user read/write permissions (i.e. 600). + A bash trap is set such that this file is removed when transcrypt exists. + +2. The first 6 bytes of the file are checked. If they are "U2FsdGVk" (which is + indicative of a salted openssl encrypted file, we assume the file is already + encrypted emit it as-is) + +3. Otherwise the transcrypt configuration is loaded (which defines the cipher, + digest, key derivation function, salt, and password), openssl is called to + encrypt the plaintext, and the base64 ciphertext is emitted and passed to git. + +The following is the openssl invocation used in encryption + +.. code:: bash + + ENC_PASS=$password openssl enc "-${cipher}" -md "${digest}" -pass env:ENC_PASS -e -a -S "$salt" "${pbkdf2_args[@]}" + + +Note: For OpenSSL V3.x, which does not prepend the salt to the ciphertext, we +manually prepend the raw salt bytes to the raw openssl output (without ``-a`` +for base64 encoding) and then perform base64 encoding of the concatenated text +as a secondary task. This makes the output from version 3.x match outputs from +the 1.x openssl releases. (Also note: this is now independently patched in +https://github.com/elasticdog/transcrypt/pull/135) + + +The Decryption Process +---------------------- + +When a sensitive file is checked out, it is first decrypted before being placed +in the user's working branch via the ``filter.crypt.smudge`` filter. + +1. The ciphertext is passed to the smudge filter via stdin. + +2. The transcrypt configuration is loaded. + +3. The ciphertext is decrypted using openssl and emitted via stdout. If + decryption fails the ciphertext itself is emitted via stdout. + + +The following invocation is used for decryption + +.. code:: bash + + # used to decrypt a file. the cipher, digest, password, and key derivation + # function must be known in advance. the salt is always prepended to the + # file ciphertext, and ready by openssl, so it does not need to be supplied here. + ENC_PASS=$password openssl enc "-${cipher}" -md "${digest}" -pass env:ENC_PASS "${pbkdf2_args[@]}" -d -a + + +Configuration +============= + +Loading the configuration is a critical subroutine in the core transcrypt +components. + +In the proposed transcrypt 3.x implementation, the following *bash* variables +are required for encryption and decryption: + +* ``cipher`` +* ``password`` +* ``digest`` +* ``pbkdf2_args`` + + +And additionally, encryption needs the variable: + +* ``salt`` + +Cipher, Password, and Digest +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +For the first 3 variables ``cipher``, ``password``, and ``digest`` the method +transcrypt uses to store them is straightforward. In the local ``.git/config`` +directory these passwords are stored as checkout-specific git variables stored +in plaintext. + +* ``transcrypt.cipher`` +* ``transcrypt.digest`` +* ``transcrypt.password`` + +Note, that before transcrypt 3.x only cipher and password were configurable. +Legacy behavior of transcrypt is described by assuming digest is MD5. + +The other two variables ``pbkdf2_args`` and ``salt`` are less straight forward. + + +PBKDF2 +~~~~~~ + +The `PBKDF2`_ (Password Based Key Derivation Function v2) adds protection +against brute force attacks by increasing the amount of time it takes to derive +the actual key and iv values used in the encryption / decryption process. + +.. _PBKDF2: https://en.wikipedia.org/wiki/PBKDF2 + +OpenSSL enables ``pbkdf2`` if the ``-pbkdf2`` flag is specified. +To coerce this into a key-value configuration scheme we use the git +configuration variable + +* ``transcrypt.use-pbkdf2`` + +Which can be set to 0 or 1. At configuration load time, depending on the value +in the config transcrypt will set ``pbkdf2_args`` to an empty bash array in the +case where pbkdf2 is disabled, and ``-pbkdf2`` otherwise. This allows us to use +bash array syntax to express both variants as a single openssl command. + +The backwards compatible setting for transcrypt < 3.x is ``--use-pbkdf2=0``. + +See Also: + +PKCS5#5.2 (RFC-2898) +https://datatracker.ietf.org/doc/html/rfc2898#section-5.2 + +Salt +~~~~ + +Lastly, there is ``salt``, which the least straightforward of these options. + +Ideally, when using openssl, a unique and random salt is generated **each +time** the file is encrypted. This prevents an attacker from executing a +known-plaintext attack by pre-computing common password / ciphertext pairs on +small files and being able to determine the user's password if any of the +precomputed ciphertexts exist in the repo. + +However, transcrypt is unable to use a random salt, because it requires +encryption to be a deterministic process. Otherwise, git would always see a +changed file every time the "clean" command was executed. + +Transcrypt therefore defines two strategies to generate a deterministic salt: + +1. The "password" salt method. +2. The "configured" salt method. + +The first method is equivalent to the existing process in transcrypt 2.x. +The second method is a new more secure variant, but will rely on a new +"versioned config" that we will discuss in +:ref:`the configuration storage section `. + +The two salt methods are very similar. In both cases, a unique 32-byte salt is +generated for each file via the following invocation: + +.. code:: bash + + # Used to compute salt for a specific file using "extra-salt" that can be supplied in one of several ways + openssl dgst -hmac "${filename}:${extra_salt}" -sha256 "$filename" | tr -d '\r\n' | tail -c 16 + +This salt is based on the name of the file, its sha256 hash, and something +called "extra-salt", which is determined by the user's choice of +``transcrypt.salt-method``. + +In the case where ``transcrypt.salt-method=password``, the "extra-salt" is set +to the user's plaintext password. This exactly mimics the behavior of +transcrypt 2.x and is used as the default to provide backwards compatibility. + +However, as discussed in +`#55 _`, this introduces a +security weakness that weakens the extra security provided the use of +``-pbkdf2``. Thus transcrypt 3.x introduces a new "configured" method. + +In the case where ``transcrypt.salt-method=configured``, the implementation +will check if a special configuration variable ``transcrypt.config-salt`` is +set, and if not, it will set it to a random 32 character hex string, and check +the choice of that value into the repo. Then the value of +``transcrypt.config-salt`` will be used as "extra-salt". The value of +``transcrypt.config-salt`` is randomized every time the user changes their +password. We note that this method this method does provide less entropy than +randomly choosing the salt on each encryption cycle, but we are unaware of +any security concerns that arise from this method. + +Note: this method could be further improved by generated a randomized +config-salt for each file that is modified when the file itself is modified. +Such a scheme should exactly match the entropy of the openssl default +randomized salt method. However, due to the added implementation complexity +and unclear security benefits we defer that to future work. + +See Also: + +PKCS5#4.1 (RFC-2898) https://datatracker.ietf.org/doc/html/rfc2898#section-4.1 + +.. _ConfigStorage: + +Configuration Storage +--------------------- + +In transcrypt 2.x, there are currently two ways to store a configuration +containing credentials and + +1. The unversioned config. +2. The GPG-exported config. + +Method 1 stores the configuration in the ``[transcrypt]`` section of the local +``.git/config`` file. This is the primary location for the configuration and +it is typically populated via specifying all settings either via an interactive +process or through non-interactive command line invocation. Whenever transcrypt +is invoked, any needed configuration variable is read from this plaintext file +using git's versatile configuration tool. + +Method 2 is used exclusively for securely transporting configurations between +machines or authorized users. The ``[transcrypt]`` section of an existing +primary configuration in the ``.git/config`` is exported into a simple new line +separated key/value store format, and then encrypted for a specific GPG user. +This encrypted file can be sent to the target recipient. They can then use +transcrypt to "import" the file, which uses +`GPG _` to decrypt the file and +populate their local unversioned ``.git/config`` file. + +In Transcrypt 3.x we propose a third configuration method: + +3. The versioned config. + +Method 3 will store the non-sensitive subset of configuration settings +(everything but ``transcrypt.password``) in a versioned ``.transcrypt/config`` +file using the same git configuration system as Method 1. + +The motivation for this is twofold. + +First, the new deterministic salt method requires a way of storing randomly +sampled bits for the salt (in the ``transcrypt.config-salt`` variable) that are +decorrelated from sensitive information (i.e. the password and contents of +decrypted files). + +Second, transcrypt 3.x adds 4 new parameters that a user will need to +configure. By storing these parameters in the repo itself it will ease the +burden of decrypting a fresh clone of a repo. + +Using this versioned config for everything but ``transcrypt.config-salt`` is +completely optional (and using ``transcrypt.config-salt`` is not needed if +``transcrypt.salt-method=password``, although that is not recommended). Thus +the user can still choose to keep the chosen cipher, digest, and use of pbkdf2 +a secret if they desire (although we will remind the reader that +`security by obscurity _` +should never be relied on). + +NOTE: Currently, as of 2022-05-09, the current implementation of transcrypt 3.x +does not implement the ability for ``.transcrypt/config`` to store any config +variable other than ``transcrypt.config-salt``. We will wait for this proposal +to be reviewed because the design of the priority in which configuration +variables are stored is is currently an open question in the mind of the +author. However, proposed example *behavior* is as follows: + +Case Study and Open Questions +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Given: A fresh clone of a repo without a ``.transcrypt/config`` file. + +The user invokes ``transcrypt`` and is prompted for all 6 configuration variables. + +These are stored to the primary ``.git/config`` file, except for +``transcrypt.config-salt``, which --- if the salt method is "configured" --- is +always stored in ``.git/transcrypt`` and checked into the repo. The user is notified +that transcrypt used ``git add`` to stage this file, and instructs the user to commit +the file (transcrypt never invokes the ``git commit`` command). + +Proposal: The user is additionally prompted if they want to add the +non-sensitive configuration to the versioned config. This prompt can be skipped +by specifying ``--versioned=1`` or ``--versioned=0``. In the unversioned case, +the process proceeded as-is, otherwise the non-sensitive configuration is written +to ``.transcrypt/config`` **instead of** being written to ``.git/config``. + +Open Question: When non-sensitive configuration variables are written, should they be: + +1. Written only to ``.transcrypt/config`` and not ``.git/config``? +2. Written to both ``.transcrypt/config`` and ``.git/config``? +3. Written only to ``.transcrypt/config`` and ensured they are removed from ``.git/config``? + +Because all of these configuration files are plain-text and editable we have to +consider the precedence of config settings when loading. The current proposal +is to always look at ``.git/config`` first and then fallback to +``.transcrypt/config``. + +Open Question: When we read a variable from ``.git/config`` and it disagrees +with ``.transcrypt/config`` do we "fix" ``.transcrypt/config``, warn, or ignore +it. My current proposal is to ignore it and rely on documented precedence +rules. diff --git a/example/end_to_end_example.sh b/example/end_to_end_example.sh new file mode 100644 index 0000000..67eb7ca --- /dev/null +++ b/example/end_to_end_example.sh @@ -0,0 +1,50 @@ +#!/bin/bash +__doc__=" +A simple demo of transcrypt +" + +TMP_DIR=$HOME/tmp/transcrypt-demo +mkdir -p "$TMP_DIR" +rm -rf "$TMP_DIR" + + +# Make a git repo and add some public content +DEMO_REPO=$TMP_DIR/repo +mkdir -p "$DEMO_REPO" +cd "$DEMO_REPO" +git init +echo "content" > README.md +git add README.md +git commit -m "add readme" + + +# Create safe directory that we will encrypt +echo " +safe/* filter=crypt diff=crypt merge=crypt +" > .gitattributes +git add .gitattributes +git commit -m "add attributes" + +mkdir -p "$DEMO_REPO"/safe + + +# Configure transcrypt with legacy defaults +transcrypt -c aes-256-cbc -p 'correct horse battery staple' -md MD5 --use-pbkdf2=0 -sm password -y + +echo "Secret contents" > "$DEMO_REPO"/safe/secret_file +cat "$DEMO_REPO"/safe/secret_file + +git add safe/secret_file +git commit -m "add secret with config1" +transcrypt -s safe/secret_file + + +# Rekey with more secure settings +transcrypt --rekey -c aes-256-cbc -p 'correct horse battery staple' -md SHA256 --use-pbkdf2=1 -sm password -y +git commit -am "changed crypto settings" + + +echo "New secret contents" >> "$DEMO_REPO"/safe/secret_file +git commit -am "added secrets" + +transcrypt -f -y diff --git a/sensitive_file b/sensitive_file index 547ad71..a5ab736 100644 Binary files a/sensitive_file and b/sensitive_file differ diff --git a/tests/local_test.sh b/tests/local_test.sh new file mode 100644 index 0000000..51f1d42 --- /dev/null +++ b/tests/local_test.sh @@ -0,0 +1,6 @@ +#./transcrypt -F -c aes-256-cbc -p "foobar" -md SHA512 -sm configured --use_pbkdf2=0 +./transcrypt -F -c aes-256-cbc -pbkdf2 -p "foobar" -md SHA512 -sm configured + +./transcrypt -d + +transcrypt --uninstall -y diff --git a/tests/test_transcrypt.py b/tests/test_transcrypt.py new file mode 100644 index 0000000..4756605 --- /dev/null +++ b/tests/test_transcrypt.py @@ -0,0 +1,513 @@ +""" +Requirements: + pip install ubelt + pip install gpg_lite + pip install GitPython +""" +import ubelt as ub + +__salt_notes__ = ''' + import base64 + salted_bytes = b'Salted' + base64.b64encode(salted_bytes) +''' +SALTED_B64 = 'U2FsdGVk' + + +class Transcrypt(ub.NiceRepr): + """ + A Python wrapper around the Transcrypt API + + Example: + >>> import sys, ubelt + >>> sys.path.append(ubelt.expandpath('~/code/transcrypt/tests')) + >>> from test_transcrypt import * # NOQA + >>> sandbox = DemoSandbox(verbose=1, dpath='special:cache').setup() + >>> config = {'digest': 'sha256', + >>> 'use_pbkdf2': '1', + >>> 'config_salt': '665896be121e1a0a4a7b18f01780061', + >>> 'salt_method': 'configured'} + >>> self = Transcrypt(sandbox.repo_dpath, + >>> config=config, env=sandbox.env, verbose=1) + >>> print(self.version()) + >>> self.config['password'] = 'chbs' + >>> self.login() + >>> sandbox.git.commit('-am', 'new salt config') + >>> print(self.display()) + >>> secret_fpath1 = self.dpath / 'safe/secret1.txt' + >>> secret_fpath2 = self.dpath / 'safe/secret2.txt' + >>> secret_fpath3 = self.dpath / 'safe/secret3.txt' + >>> secret_fpath1.write_text('secret message 1') + >>> secret_fpath2.write_text('secret message 2') + >>> secret_fpath3.write_text('secret message 3') + >>> sandbox.git.add(secret_fpath1, secret_fpath2, secret_fpath3) + >>> sandbox.git.commit('-am', 'add secret messages') + >>> encrypted_paths = self.list() + >>> assert len(encrypted_paths) == 3 + >>> assert self.show_raw(secret_fpath1) == 'U2FsdGVkX18147KP5UmqOFywveuOGf4hCwrWpfJDp3Ah0HHbFPEGdJE0kM4npWzI' + >>> assert self.show_raw(secret_fpath2) == 'U2FsdGVkX183LEAwwnJ0ne/OKU5VANJsOqCA92Oi9hVkKHIwZYiCgJOoedoShPj7' + >>> assert self.show_raw(secret_fpath3) == 'U2FsdGVkX1/NdLm6twCdF3xYLPCfXacDNsHEeGq0UBC1fwTlJKnN2KmPysS/ylPj' + """ + default_config = { + 'cipher': 'aes-256-cbc', + 'password': None, + 'digest': 'md5', + 'use_pbkdf2': '0', + 'salt_method': 'password', + 'config_salt': '', + } + + def __init__(self, dpath, config=None, env=None, transcript_exe=None, verbose=0): + self.dpath = dpath + self.verbose = verbose + self.transcript_exe = ub.Path(ub.find_exe('transcrypt')) + self.env = {} + self.config = self.default_config.copy() + if env is not None: + self.env.update(env) + if config: + self.config.update(config) + + def __nice__(self): + return '{}, {}'.format(self.dpath, ub.repr2(self.config)) + + def _cmd(self, command, shell=False, check=True, verbose=None): + """ + Helper to execute underlying transcrypt commands + """ + if verbose is None: + verbose = self.verbose + return ub.cmd(command, cwd=self.dpath, verbose=verbose, env=self.env, + shell=shell, check=check) + + def _config_args(self): + arg_templates = [ + "-c", self.config['cipher'], + "-p", self.config['password'], + "-md", self.config['digest'], + "--use-pbkdf2", self.config['use_pbkdf2'], + "-sm", self.config['salt_method'], + "-cs", self.config['config_salt'], + ] + args = [template.format(**self.config) for template in arg_templates] + return args + + def is_configured(self): + """ + Determine if the transcrypt credentials are populated in the repo + + Returns: + bool : True if the repo is configured with credentials + """ + info = self._cmd(f'{self.transcript_exe} -d', check=0, verbose=0) + return info['ret'] == 0 + + def login(self): + """ + Configure credentials + """ + args = self._config_args() + command = [str(self.transcript_exe), *args, '-y'] + self._cmd(command) + + def logout(self): + """ + Flush credentials + """ + self._cmd(f'{self.transcript_exe} -f -y') + + def rekey(self, new_config): + """ + Re-encrypt all encrypted files using new credentials + """ + self.config.update(new_config) + args = self._config_args() + command = [str(self.transcript_exe), '--rekey', *args, '-y'] + self._cmd(command) + + def display(self): + """ + Returns: + str: the configuration details of the repo + """ + return self._cmd(f'{self.transcript_exe} -d')['out'].rstrip() + + def version(self): + """ + Returns: + str: the version + """ + return self._cmd(f'{self.transcript_exe} --version')['out'].rstrip() + + def _crypt_dir(self): + info = self._cmd('git config --local transcrypt.crypt-dir', check=0) + if info['err'] == 0: + crypt_dpath = ub.Path(info['out'].strip()) + else: + crypt_dpath = self.dpath / '.git/crypt' + return crypt_dpath + + def export_gpg(self, recipient): + """ + Encode the transcrypt credentials securely in an encrypted gpg message + + Returns: + Path: path to the gpg encrypted file containing the repo config + """ + self._cmd(f'{self.transcript_exe} --export-gpg "{recipient}"') + crypt_dpath = self._crypt_dir() + asc_fpath = (crypt_dpath / (recipient + '.asc')) + return asc_fpath + + def import_gpg(self, asc_fpath): + """ + Configure the repo using a given gpg encrypted file + """ + command = f"{self.transcript_exe} --import-gpg '{asc_fpath}' -y" + self._cmd(command) + + def show_raw(self, fpath): + """ + Show the encrypted contents of a file that will be publicly viewable + """ + return self._cmd(f'{self.transcript_exe} -s {fpath}')['out'].rstrip() + + def list(self): + """ + Returns: + List[str]: relative paths of all files managed by transcrypt + """ + result = self._cmd(f'{self.transcript_exe} --list')['out'].rstrip() + paths = result.split('\n') + return paths + + def uninstall(self): + """ + Flushes credentials and removes transcrypt files + """ + return self._cmd(f'{self.transcript_exe} --uninstall -y') + + def upgrade(self): + """ + Upgrades a configured repo to "this" version of transcrypt + """ + return self._cmd(f'{self.transcript_exe} --upgrade -y') + + def _load_local_config(self): + local_config = { + 'cipher': self._cmd('git config --get --local transcrypt.cipher')['out'].strip(), + 'digest': self._cmd('git config --get --local transcrypt.digest')['out'].strip(), + 'use_pbkdf2': self._cmd('git config --get --local transcrypt.use-pbkdf2')['out'].strip(), + 'salt_method': self._cmd('git config --get --local transcrypt.salt-method')['out'].strip(), + 'password': self._cmd('git config --get --local transcrypt.password')['out'].strip(), + 'openssl_path': self._cmd('git config --get --local transcrypt.openssl-path')['out'].strip(), + } + if local_config['salt_method'] == 'configured': + tc_config_path = self.dpath / '.transcrypt/config' + local_config['config_salt'] = self._cmd(f'git config --get --file {tc_config_path} transcrypt.config-salt')['out'].strip() + return local_config + + +class DemoSandbox(ub.NiceRepr): + """ + A environment for demo / testing of the transcrypt API + """ + def __init__(self, dpath=None, verbose=0): + if dpath is None: + dpath = 'special:temp' + + if dpath == 'special:temp': + import tempfile + self._tmpdir = tempfile.TemporaryDirectory() + dpath = self._tmpdir.name + elif dpath == 'special:cache': + dpath = ub.Path.appdir('transcrypt/tests/test_env') + self.env = {} + self.dpath = ub.Path(dpath) + self.gpg_store = None + self.repo_dpath = None + self.git = None + self.verbose = verbose + + def __nice__(self): + return str(self.dpath) + + def setup(self): + self._setup_gpghome() + self._setup_gitrepo() + self._setup_contents() + return self + + def _setup_gpghome(self): + if self.verbose: + print('setup sandbox gpghome') + import gpg_lite + self.gpg_home = (self.dpath / 'gpg').ensuredir() + self.gpg_store = gpg_lite.GPGStore( + gnupg_home_dir=self.gpg_home + ) + self.gpg_fpr = self.gpg_store.gen_key( + full_name='Emmy Noether', + email='emmy.noether@uni-goettingen.de', + passphrase=None, + key_type='eddsa', + subkey_type='ecdh', + key_curve='Ed25519', + subkey_curve='Curve25519' + ) + # Fix GNUPG permissions + (self.gpg_home / 'private-keys-v1.d').ensuredir() + # 600 for files and 700 for directories + ub.cmd('find ' + str(self.gpg_home) + r' -type f -exec chmod 600 {} \;', shell=True, cwd=self.gpg_home) + ub.cmd('find ' + str(self.gpg_home) + r' -type d -exec chmod 700 {} \;', shell=True, cwd=self.gpg_home) + self.env['GNUPGHOME'] = str(self.gpg_home) + + def _setup_gitrepo(self): + if self.verbose: + print('setup sandbox gitrepo') + import git + # Make a git repo and add some public content + repo_name = 'demo-repo' + self.repo_dpath = (self.dpath / repo_name).ensuredir() + # self.repo_dpath.delete().ensuredir() + self.repo_dpath.ensuredir() + + for content in self.repo_dpath.iterdir(): + content.delete() + + self.git = git.Git(self.repo_dpath) + self.git.init() + + def _setup_contents(self): + if self.verbose: + print('setup sandbox git contents') + readme_fpath = (self.repo_dpath / 'README.md') + readme_fpath.write_text('content') + self.git.add(readme_fpath) + + # Create safe directory that we will encrypt + gitattr_fpath = self.repo_dpath / '.gitattributes' + gitattr_fpath.write_text(ub.codeblock( + ''' + safe/* filter=crypt diff=crypt merge=crypt + ''')) + self.git.add(gitattr_fpath) + self.git.commit('-am Add initial contents') + self.safe_dpath = (self.repo_dpath / 'safe').ensuredir() + self.secret_fpath = self.safe_dpath / 'secret.txt' + self.secret_fpath.write_text('secret content') + + def _manual_hack_info(self): + """ + Info on how to get an env to run a failing command manually + """ + for k, v in self.env.items(): + print(f'export {k}={v}') + print(f'cd {self.repo_dpath}') + + +class TestCases: + """ + Unit tests to be applied to different transcrypt configurations + """ + + def __init__(self, config=None, dpath=None, verbose=0): + if config is None: + config = Transcrypt.default_config + config['password'] = '12345' + self.config = config + self.verbose = verbose + self.sandbox = None + self.tc = None + self.dpath = dpath + + def setup(self): + self.sandbox = DemoSandbox(dpath=self.dpath, verbose=self.verbose) + self.sandbox.setup() + self.tc = Transcrypt( + dpath=self.sandbox.repo_dpath, + config=self.config, + env=self.sandbox.env, + verbose=self.verbose, + ) + assert not self.tc.is_configured() + self.tc.login() + secret_fpath = self.sandbox.secret_fpath + self.sandbox.git.add(secret_fpath) + self.sandbox.git.commit('-am add secret') + self.tc.display() + + def test_round_trip(self): + secret_fpath = self.sandbox.secret_fpath + ciphertext = self.tc.show_raw(secret_fpath) + plaintext = secret_fpath.read_text() + assert ciphertext.startswith(SALTED_B64) + assert plaintext.startswith('secret content') + assert not plaintext.startswith(SALTED_B64) + + self.tc.logout() + logged_out_text = secret_fpath.read_text().rstrip() + assert logged_out_text == ciphertext + + self.tc.login() + logged_in_text = secret_fpath.read_text().rstrip() + + assert logged_out_text == ciphertext + assert logged_in_text == plaintext + + def test_export_gpg(self): + self.tc.display() + recipient = self.sandbox.gpg_fpr + asc_fpath = self.tc.export_gpg(recipient) + + info = self.tc._cmd(f'gpg --batch --quiet --decrypt "{asc_fpath}"') + content = info['out'] + + got_config = dict([p.split('=', 1) for p in content.split('\n') if p]) + config = self.tc.config.copy() + is_ok = got_config == config + if not is_ok: + if config['salt_method'] == 'configured': + if config['config_salt'] == '': + config.pop('config_salt') + got_config.pop('config_salt') + is_ok = got_config == config + else: + config.pop('config_salt') + got_config.pop('config_salt') + is_ok = got_config == config + + if not is_ok: + print(f'got_config={got_config}') + print(f'config={config}') + raise AssertionError + + assert asc_fpath.exists() + self.tc.logout() + self.tc.import_gpg(asc_fpath) + + secret_fpath = self.sandbox.secret_fpath + plaintext = secret_fpath.read_text() + assert plaintext.startswith('secret content') + + def test_rekey(self): + new_config = { + 'cipher': 'aes-256-cbc', + 'password': '12345', + 'digest': 'sha256', + 'use_pbkdf2': '1', + 'salt_method': 'configured', + } + raw_before = self.tc.show_raw(self.sandbox.secret_fpath) + self.tc.rekey(new_config) + self.sandbox.git.commit('-am commit rekey') + raw_after = self.tc.show_raw(self.sandbox.secret_fpath) + assert raw_before != raw_after + + +def test_legacy_defaults(): + config = { + 'cipher': 'aes-256-cbc', + 'password': 'correct horse battery staple', + 'digest': 'md5', + 'use_pbkdf2': '0', + 'salt_method': 'password', + } + verbose = 1 + self = TestCases(config=config, verbose=verbose) + self.setup() + self.test_round_trip() + self.test_export_gpg() + + +def test_secure_defaults(): + config = { + 'cipher': 'aes-256-cbc', + 'password': 'correct horse battery staple', + 'digest': 'sha512', + 'use_pbkdf2': '1', + 'salt_method': 'configured', + } + verbose = 1 + self = TestCases(config=config, verbose=verbose) + self.setup() + self.test_round_trip() + self.test_export_gpg() + + +def test_configured_salt_changes_on_rekey(): + config = { + 'cipher': 'aes-256-cbc', + 'password': 'correct horse battery staple', + 'digest': 'sha512', + 'use_pbkdf2': '1', + 'salt_method': 'configured', + } + verbose = 1 + self = TestCases(config=config, verbose=verbose) + self.setup() + before_config = self.tc._load_local_config() + self.tc.rekey({'password': '12345', 'config_salt': ''}) + self.sandbox.git.commit('-am commit rekey') + after_config = self.tc._load_local_config() + assert before_config['config_salt'] != after_config['config_salt'] + assert before_config['password'] != after_config['password'] + assert before_config['cipher'] == after_config['cipher'] + assert before_config['use_pbkdf2'] == after_config['use_pbkdf2'] + assert before_config['salt_method'] == after_config['salt_method'] + assert before_config['openssl_path'] == after_config['openssl_path'] + + +def test_configuration_grid(): + """ + CommandLine: + xdoctest -m tests/test_transcrypt.py test_configuration_grid + + Example: + >>> import sys, ubelt + >>> sys.path.append(ubelt.expandpath('~/code/transcrypt/tests')) + >>> from test_transcrypt import * # NOQA + >>> self = TestCases() + >>> self.setup() + >>> self.sandbox._manual_hack_info() + >>> self.test_round_trip() + >>> self.test_export_gpg() + + self = TestEnvironment(config={'use_pbkdf2': 1}) + self.setup() + self.test_round_trip() + self.test_export_gpg() + + self = TestEnvironment(config={'use_pbkdf2': 1}) + """ + # Test that transcrypt works under a variety of config conditions + basis = { + 'cipher': ['aes-256-cbc', 'aes-128-ecb'], + 'password': ['correct horse battery staple'], + 'digest': ['md5', 'sha256'], + 'use_pbkdf2': ['0', '1'], + 'salt_method': ['password', 'configured'], + 'config_salt': ['', 'mylittlecustomsalt'], + } + test_grid = list(ub.named_product(basis)) + dpath = 'special:temp' + dpath = 'special:cache' + for params in ub.ProgIter(test_grid, desc='test configs', freq=1): + config = params.copy() + self = TestCases(config=config, dpath=dpath) + self.setup() + if 0: + # Manual debug + self.sandbox._manual_hack_info() + + self.test_round_trip() + self.test_export_gpg() + self.test_rekey() + + +if __name__ == '__main__': + """ + CommandLine: + python ~/code/transcrypt/tests/test_transcrypt.py + """ + test_configuration_grid() diff --git a/tools/fix_indentation.py b/tools/fix_indentation.py new file mode 100644 index 0000000..dfd895e --- /dev/null +++ b/tools/fix_indentation.py @@ -0,0 +1,38 @@ +def main(): + import ubelt as ub + import xdev + fpath = ub.Path('$HOME/code/transcrypt/transcrypt').expand() + text = fpath.read_text() + lines = text.split('\n') + + tabstop = 4 + indent_pat = xdev.Pattern.from_regex(r'(\s*)(.*)') + space_pat = xdev.Pattern.from_regex(r' ' * tabstop) + + in_usage = 0 + new_lines = [] + for line in lines: + if 'cat <<-EOF' == line.strip(): + in_usage = True + if 'EOF' == line.strip(): + in_usage = False + indent, suffix = indent_pat.match(line).groups() + hist = ub.dict_hist(indent) + ntabs = hist.get('\t', 0) + if in_usage: + # Only have 2 leading tabs in the usage part + new_indent = space_pat.sub('\t', indent, count=(2 - ntabs)) + else: + new_indent = space_pat.sub('\t', indent) + new_line = new_indent + suffix + new_lines.append(new_line) + + fpath.write_text('\n'.join(new_lines)) + + +if __name__ == '__main__': + """ + CommandLine: + python tools/fix_indentation.py + """ + main() diff --git a/transcrypt b/transcrypt index 0319f48..bebba64 100755 --- a/transcrypt +++ b/transcrypt @@ -1,6 +1,10 @@ #!/usr/bin/env bash set -euo pipefail +if [[ "${TRANSCRYPT_TRACE+x}" != "" ]]; then + set -x +fi + # # transcrypt - https://github.com/elasticdog/transcrypt # @@ -16,10 +20,23 @@ set -euo pipefail ##### CONSTANTS # the release version of this script -readonly VERSION='2.2.0-pre' +readonly VERSION='3.0.0-pre' # the default cipher to utilize readonly DEFAULT_CIPHER='aes-256-cbc' +readonly DEFAULT_DIGEST='MD5' +readonly DEFAULT_USE_PBKDF2='0' +readonly DEFAULT_SALT_METHOD='password' + +# These are the implemented methods for computing deterministic salt +readonly VALID_SALT_METHODS="password configured" + +# These are config variables we do not allow to be used in the versioned +# configuration +readonly VERSIONED_CONFIG_BLOCKLIST="transcrypt.password transcrypt.openssl-path" + +# Set to 1 to enable a development editable installation +readonly EDITABLE_INSTALL=${TRANSCRYPT_EDITABLE_INSTALL:=0} ##### FUNCTIONS @@ -51,6 +68,186 @@ realpath() { fi } +_openssl_encrypt() { + # Test the openssl version + openssl_major_version=$($openssl_path version | cut -d' ' -f2 | cut -d'.' -f1) + if [ "$openssl_major_version" -ge "3" ]; then + # OpenSSL 3.x + # In 3.x openssl disabled output of the salt prefix, which we need for determinism. + # To reenable the prefix we emit the raw prefix bytes, encrypt in raw bytes, and then + # send that entire stream to be base64 encoded + ( + printf "Salted__" && printf "%s" "$salt" | xxd -r -p && + ENC_PASS=$password "$openssl_path" enc "-${cipher}" -md "${digest}" -pass env:ENC_PASS -e -S "$salt" "${pbkdf2_args[@]}" -in "$tempfile" + ) | base64 + else + # OpenSSL 1.x + ENC_PASS=$password "$openssl_path" enc "-${cipher}" -md "${digest}" -pass env:ENC_PASS -e -a -S "$salt" "${pbkdf2_args[@]}" -in "$tempfile" + fi +} + +_openssl_decrypt() { + # Exepcts that the following variables are set: + # password, openssl_path, cipher, digest, pbkdf2_args + # This works the same across openssl versions + ENC_PASS=$password "$openssl_path" enc "-${cipher}" -md "${digest}" -pass env:ENC_PASS "${pbkdf2_args[@]}" -d -a +} + +# compatible openssl list command +_openssl_list() { + arg=$1 + if "${openssl_path} list-$arg" &>/dev/null; then + # OpenSSL < v1.1.0 + "${openssl_path}" "list-$arg" + else + # OpenSSL >= v1.1.0 + "${openssl_path}" "list" "-$arg" + fi +} + +# sets a bash global variable by name +_set_global() { + key=$1 + val=$2 + printf -v "$key" '%s' "$val" +} + +# Checks if the target variable is in the set of valid values. If it is not, it +# unsets the target variable, then if not in interactive mode it calls die. +_validate_variable_str() { + local varname=$1 + local valid_values=$2 + local varval=${!varname} + if ! _is_contained_str "$varval" "$valid_values"; then + message=$(printf '%s is `%s`, but must be one of: %s' "$varname" "$varval" "$valid_values") + if [[ $interactive ]]; then + _set_global "$varname" "" + echo "$message" + else + die 1 "$message" + fi + fi +} + +# Helper to prompt the user, store a response, and validate the result +_get_user_input() { + local varname=$1 + local default=$2 + local validate_fn=$3 + local prompt=$4 + + while [[ ! ${!varname} ]]; do + local answer= + if [[ $interactive ]]; then + printf '%s' "$prompt" + read -r answer + fi + # use the default value if the user gave no answer; otherwise call the + # validate function, which should set the varname to empty if it is + # invalid and the user should continue, otherwise it should die. + if [[ ! $answer ]]; then + _set_global "$varname" "$default" + else + _set_global "$varname" "$answer" + ${validate_fn} + fi + done +} + +# Check if the first arg is contained in the space separated second arg +_is_contained_str() { + arg=$1 + values=$2 + echo "$values" | tr -s ' ' '\n' | grep -Fx "$arg" &>/dev/null +} + +# Load a config var from the versioned config +# shellcheck disable=SC2155 +_load_versioned_config_var() { + # the current git repository's top-level directory + if [ -z "${VERSIONED_TC_CONFIG+x}" ]; then + readonly REPO=$(git rev-parse --show-toplevel 2>/dev/null) + # This is the place where transcrypt can store state that will be + # "versioned" (i.e. checked into the repo) + readonly VERSIONED_TC_DIR="${REPO}/.transcrypt" + readonly VERSIONED_TC_CONFIG="${VERSIONED_TC_DIR}/config" + fi + local key=$1 + # Test for blocked variables that should not go into a plaintext config file + if _is_contained_str "${key}" "${VERSIONED_CONFIG_BLOCKLIST}"; then + warn "Cannot use ${key} in versioned the transcrypt config" + return 1 + fi + git config --file "${VERSIONED_TC_CONFIG}" --get "${key}" || true +} + +# Write a config var to the versioned config +_set_versioned_config_var() { + local key=$1 + local val=$2 + # Test for blocked variables that should not go into a plaintext config file + if _is_contained_str "${key}" "${VERSIONED_CONFIG_BLOCKLIST}"; then + warn "Cannot use ${key} in versioned the transcrypt config" + return 1 + fi + mkdir -p "${VERSIONED_TC_DIR}" + git config --file "${VERSIONED_TC_CONFIG}" "${key}" "${val}" +} + +# +_load_config_var() { + # First try loading from the local checkout-independent .git/config file + # If that doesn't work, then look in the .transcrypt/config file + # (which is expected to be stored in plaintext and checked into the repo) + # Certain values will be blocked from being placed here (like the password) + local key=$1 + git config --get --local "${key}" + if [[ "$?" != "0" ]]; then + _load_versioned_config_var "${key}" + fi +} + +# shellcheck disable=SC2155 +_load_transcrypt_config_vars() { + # Populate bash vars with our config + cipher=$(git config --get --local transcrypt.cipher) || (echo "failed to load transcrypt.cipher" && false) + digest=$(git config --get --local transcrypt.digest) || (echo "failed to load transcrypt.digest" && false) + use_pbkdf2=$(git config --get --local transcrypt.use-pbkdf2) || (echo "failed to load transcrypt.use-pbkdf2" && false) + salt_method=$(git config --get --local transcrypt.salt-method) || (echo "failed to load transcrypt.salt-method" && false) + + password=$(git config --get --local transcrypt.password) || (echo "failed to load transcrypt.password" && false) + openssl_path=$(git config --get --local transcrypt.openssl-path) || (echo "failed to load transcrypt.openssl-path" && false) + + if [[ "$salt_method" == "configured" ]]; then + config_salt=$(_load_versioned_config_var "transcrypt.config-salt") + else + config_salt="" + fi +} + +_load_vars_for_encryption() { + # Helper to populate variables needed to call openssl encryption + _load_transcrypt_config_vars + + if [[ "$use_pbkdf2" == "1" ]]; then + pbkdf2_args=('-pbkdf2') + else + pbkdf2_args=() + fi + + if [[ "$salt_method" == "password" ]]; then + extra_salt=$password + elif [[ "$salt_method" == "configured" ]]; then + extra_salt=$config_salt + else + die "unknown salt method" + fi + + if [[ "$extra_salt" == "" ]]; then + die "Extra salt is not set" + fi +} + # establish repository metadata and directory handling # shellcheck disable=SC2155 gather_repo_metadata() { @@ -90,6 +287,13 @@ gather_repo_metadata() { else readonly GIT_ATTRIBUTES="${REPO}/.gitattributes" fi + + # This is the place where transcrypt can store state that will be + # "versioned" (i.e. checked into the repo) + readonly RELATIVE_VERSIONED_TC_DIR=".transcrypt" + readonly RELATIVE_VERSIONED_TC_CONFIG="${RELATIVE_VERSIONED_TC_DIR}/config" + readonly VERSIONED_TC_DIR="${REPO}/${RELATIVE_VERSIONED_TC_DIR}" + readonly VERSIONED_TC_CONFIG="${REPO}/${RELATIVE_VERSIONED_TC_CONFIG}" } # print a message to stderr @@ -116,52 +320,64 @@ die() { # deterministic for everything to work transparently. To do that, the same # salt must be used each time we encrypt the same file. An HMAC has been # proven to be a PRF, so we generate an HMAC-SHA256 for each decrypted file -# (keyed with a combination of the filename and transcrypt password), and +# (keyed with a combination of the filename and ~~transcrypt password~~), and # then use the last 16 bytes of that HMAC for the file's unique salt. - git_clean() { + + # The clean script encrypts files before git sends them to the remote. + # Note the "Salted" check is part of openssl and not anything we do here. + # It allows anyone (including us) to check if a file was already encrypted + # but this does compromise the encrypted stream of data (which starts on + # the 17th byte). + # References: https://crypto.stackexchange.com/questions/8776/what-is-u2fsdgvkx1 filename=$1 # ignore empty files if [[ ! -s $filename ]]; then return fi # cache STDIN to test if it's already encrypted + # First, create the tempfile, then + # set a trap to remove the tempfile when we exit or if anything goes wrong + # finally write the stdin of this script to the tempfile tempfile=$(mktemp 2>/dev/null || mktemp -t tmp) trap 'rm -f "$tempfile"' EXIT tee "$tempfile" &>/dev/null # the first bytes of an encrypted file are always "Salted" in Base64 # The `head + LC_ALL=C tr` command handles binary data in old and new Bash (#116) + # this is an openssl standard. The actual encrypted stream starts on the 17th byte. firstbytes=$(head -c8 "$tempfile" | LC_ALL=C tr -d '\0') if [[ $firstbytes == "U2FsdGVk" ]]; then + # The file is already encrypted, so just pass it back cat "$tempfile" else - cipher=$(git config --get --local transcrypt.cipher) - password=$(git config --get --local transcrypt.password) - openssl_path=$(git config --get --local transcrypt.openssl-path) - salt=$("${openssl_path}" dgst -hmac "${filename}:${password}" -sha256 "$tempfile" | tr -d '\r\n' | tail -c16) - ENC_PASS=$password "$openssl_path" enc "-${cipher}" -md MD5 -pass env:ENC_PASS -e -a -S "$salt" -in "$tempfile" + _load_vars_for_encryption + # NOTE: the openssl standard for salt is 16 hex bytes. + salt=$("$openssl_path" dgst -hmac "${filename}:${extra_salt}" -sha256 "$filename" | tr -d '\r\n' | tail -c 16) + _openssl_encrypt fi } git_smudge() { + # The smudge script decrypts files when they are checked out by an authenticated repository. + # the file contents are passed via stdin tempfile=$(mktemp 2>/dev/null || mktemp -t tmp) trap 'rm -f "$tempfile"' EXIT - cipher=$(git config --get --local transcrypt.cipher) - password=$(git config --get --local transcrypt.password) - openssl_path=$(git config --get --local transcrypt.openssl-path) - tee "$tempfile" | ENC_PASS=$password "$openssl_path" enc "-${cipher}" -md MD5 -pass env:ENC_PASS -d -a 2>/dev/null || cat "$tempfile" + #_load_transcrypt_config_vars + _load_vars_for_encryption + tee "$tempfile" | _openssl_decrypt 2>/dev/null || cat "$tempfile" } git_textconv() { + # The textconv script allows users to see git diffs in plaintext. + # It does this by decrypting the encrypted git globs into plain text before + # passing them to the diff command. filename=$1 # ignore empty files if [[ ! -s $filename ]]; then return fi - cipher=$(git config --get --local transcrypt.cipher) - password=$(git config --get --local transcrypt.password) - openssl_path=$(git config --get --local transcrypt.openssl-path) - ENC_PASS=$password "$openssl_path" enc "-${cipher}" -md MD5 -pass env:ENC_PASS -d -a -in "$filename" 2>/dev/null || cat "$filename" + _load_transcrypt_config_vars + _openssl_decrypt -in "$filename" 2>/dev/null || cat "$filename" } # shellcheck disable=SC2005,SC2002,SC2181 @@ -223,6 +439,7 @@ git_pre_commit() { : # Do nothing # The first bytes of an encrypted file must be "Salted" in Base64 elif [[ $firstbytes != "U2FsdGVk" ]]; then + echo "firstbytes = $firstbytes" printf 'Transcrypt managed file is not encrypted in the Git index: %s\n' "$secret_file" >&2 printf '\n' >&2 printf 'You probably staged this file using a tool that does not apply' >&2 @@ -311,48 +528,49 @@ run_safety_checks() { # unset the cipher variable if it is not supported by openssl validate_cipher() { - local list_cipher_commands - if "${openssl_path}" list-cipher-commands &>/dev/null; then - # OpenSSL < v1.1.0 - list_cipher_commands="${openssl_path} list-cipher-commands" - else - # OpenSSL >= v1.1.0 - list_cipher_commands="${openssl_path} list -cipher-commands" - fi + local valid_ciphers + valid_ciphers=$(_openssl_list cipher-commands) + _validate_variable_str "cipher" "$valid_ciphers" +} - local supported - supported=$($list_cipher_commands | tr -s ' ' '\n' | grep -Fx "$cipher") || true - if [[ ! $supported ]]; then - if [[ $interactive ]]; then - printf '"%s" is not a valid cipher; choose one of the following:\n\n' "$cipher" - $list_cipher_commands | column -c 80 - printf '\n' - cipher='' - else - # shellcheck disable=SC2016 - die 1 '"%s" is not a valid cipher; see `%s`' "$cipher" "$list_cipher_commands" - fi - fi +validate_digest() { + local valid_digests + valid_digests=$(_openssl_list digest-commands) + _validate_variable_str "digest" "$valid_digests" +} + +validate_use_pbkdf2() { + _validate_variable_str "use_pbkdf2" "0 1" +} + +validate_salt_method() { + _validate_variable_str "salt_method" "$VALID_SALT_METHODS" +} + +# ensure we have a digest to hash the salted password +get_digest() { + local prompt + prompt=$(printf 'Encrypt using which digest? [%s] ' "$DEFAULT_DIGEST") + _get_user_input digest "$DEFAULT_DIGEST" "validate_digest" "$prompt" } # ensure we have a cipher to encrypt with get_cipher() { - while [[ ! $cipher ]]; do - local answer= - if [[ $interactive ]]; then - printf 'Encrypt using which cipher? [%s] ' "$DEFAULT_CIPHER" - read -r answer - fi + local prompt + prompt=$(printf 'Encrypt using which cipher? [%s] ' "$DEFAULT_CIPHER") + _get_user_input cipher "$DEFAULT_CIPHER" "validate_cipher" "$prompt" +} - # use the default cipher if the user gave no answer; - # otherwise verify the given cipher is supported by openssl - if [[ ! $answer ]]; then - cipher=$DEFAULT_CIPHER - else - cipher=$answer - validate_cipher - fi - done +get_use_pbkdf2() { + local prompt + prompt=$(printf 'Use pbkdf2? [%s] ' "$DEFAULT_USE_PBKDF2") + _get_user_input use_pbkdf2 "$DEFAULT_USE_PBKDF2" "validate_use_pbkdf2" "$prompt" +} + +get_salt_method() { + local prompt + prompt=$(printf 'Compute salt using which method? [%s] ' "$DEFAULT_SALT_METHOD") + _get_user_input salt_method "$DEFAULT_SALT_METHOD" "validate_salt_method" "$prompt" } # ensure we have a password to encrypt with @@ -384,13 +602,9 @@ get_password() { confirm_configuration() { local answer= - printf '\nRepository metadata:\n\n' - [[ ! $REPO ]] || printf ' GIT_WORK_TREE: %s\n' "$REPO" - printf ' GIT_DIR: %s\n' "$GIT_DIR" - printf ' GIT_ATTRIBUTES: %s\n\n' "$GIT_ATTRIBUTES" + _display_git_configuration printf 'The following configuration will be saved:\n\n' - printf ' CIPHER: %s\n' "$cipher" - printf ' PASSWORD: %s\n\n' "$password" + _display_runtime_configuration printf 'Does this look correct? [Y/n] ' read -r -n 1 -s answer @@ -407,13 +621,9 @@ confirm_configuration() { confirm_rekey() { local answer= - printf '\nRepository metadata:\n\n' - [[ ! $REPO ]] || printf ' GIT_WORK_TREE: %s\n' "$REPO" - printf ' GIT_DIR: %s\n' "$GIT_DIR" - printf ' GIT_ATTRIBUTES: %s\n\n' "$GIT_ATTRIBUTES" + _display_git_configuration printf 'The following configuration will be saved:\n\n' - printf ' CIPHER: %s\n' "$cipher" - printf ' PASSWORD: %s\n\n' "$password" + _display_runtime_configuration printf 'You are about to re-encrypt all encrypted files using new credentials.\n' printf 'Once you do this, their historical diffs will no longer display in plain text.\n\n' printf 'Proceed with rekey? [y/N] ' @@ -450,8 +660,12 @@ save_helper_scripts() { local current_transcrypt current_transcrypt=$(realpath "$0" 2>/dev/null) - cp "$current_transcrypt" "${CRYPT_DIR}/transcrypt" - + if [[ "$EDITABLE_INSTALL" == "1" ]]; then + # Editable mode is for debugging + ln -fs "$current_transcrypt" "${CRYPT_DIR}/transcrypt" + else + cp "$current_transcrypt" "${CRYPT_DIR}/transcrypt" + fi # make scripts executable for script in {transcrypt,}; do chmod 0755 "${CRYPT_DIR}/${script}" @@ -484,16 +698,47 @@ save_helper_hooks() { fi } -# write the configuration to the repository's git config +# "install" transcrypt by writing the configuration to the repository's git config save_configuration() { save_helper_scripts save_helper_hooks # write the encryption info git config transcrypt.version "$VERSION" + + git config transcrypt.openssl-path "$openssl_path" + git config transcrypt.cipher "$cipher" + git config transcrypt.digest "$digest" + git config transcrypt.use-pbkdf2 "$use_pbkdf2" + git config transcrypt.salt-method "$salt_method" + git config transcrypt.password "$password" - git config transcrypt.openssl-path "$openssl_path" + + # TODO: We may want to allow repo settings to be stored here as well. + #if [[ "$use_versioned_config" == "1" ]]; then + # _set_versioned_config_var "transcrypt.version" "$VERSION" + # _set_versioned_config_var "transcrypt.cipher" "$cipher" + # _set_versioned_config_var "transcrypt.digest" "$digest" + # _set_versioned_config_var "transcrypt.use-pbkdf2" "$use_pbkdf2" + # _set_versioned_config_var "transcrypt.salt-method" "$salt_method" + #fi + if [[ "$salt_method" == "configured" ]]; then + # TODO: we may want to also write the config-salt variable to the local config + # The user might not care about cross machine transparency + git config transcrypt.config-salt "$config_salt" + _set_versioned_config_var "transcrypt.config-salt" "$config_salt" + if ! git ls-files --error-unmatch "$RELATIVE_VERSIONED_TC_CONFIG" >/dev/null 2>&1; then + git add "${RELATIVE_VERSIONED_TC_CONFIG}" + printf "*** The contents of %s were configured. ***\n" "${RELATIVE_VERSIONED_TC_CONFIG}" + printf '*** COMMIT THIS CHANGE RIGHT AWAY! ***\n\n' + fi + if ! git diff --exit-code "$RELATIVE_VERSIONED_TC_CONFIG" >/dev/null 2>&1; then + git add "${RELATIVE_VERSIONED_TC_CONFIG}" + printf "*** The contents of %s were updated. ***\n" "${RELATIVE_VERSIONED_TC_CONFIG}" + printf '*** COMMIT THIS CHANGE RIGHT AWAY! ***\n\n' + fi + fi # write the filter settings. Sorry for the horrific quote escaping below... # shellcheck disable=SC2016 @@ -514,23 +759,51 @@ save_configuration() { git config alias.ls-crypt "!git -c core.quotePath=false ls-files | git -c core.quotePath=false check-attr --stdin filter | awk 'BEGIN { FS = \":\" }; /crypt$/{ print \$1 }'" } +_display_git_configuration() { + printf '\nRepository metadata:\n\n' + [[ ! $REPO ]] || printf ' GIT_WORK_TREE: %s\n' "$REPO" + printf ' GIT_DIR: %s\n' "$GIT_DIR" + printf ' GIT_ATTRIBUTES: %s\n\n' "$GIT_ATTRIBUTES" +} + +# Show the config of the current runtime +_display_runtime_configuration() { + printf ' DIGEST: %s\n' "$digest" + printf ' USE_PBKDF2: %s\n' "$use_pbkdf2" + printf ' SALT_METHOD: %s\n' "$salt_method" + if [[ "$salt_method" == "configured" ]]; then + printf ' CONFIG_SALT: %s\n' "$config_salt" + fi + printf ' CIPHER: %s\n' "$cipher" + printf ' PASSWORD: %s\n\n' "$password" +} + # display the current configuration settings display_configuration() { - local current_cipher - current_cipher=$(git config --get --local transcrypt.cipher) - local current_password - current_password=$(git config --get --local transcrypt.password) - local escaped_password=${current_password//\'/\'\\\'\'} - + _load_transcrypt_config_vars + local escaped_password=${password//\'/\'\\\'\'} printf 'The current repository was configured using transcrypt version %s\n' "$CONFIGURED" printf 'and has the following configuration:\n\n' - [[ ! $REPO ]] || printf ' GIT_WORK_TREE: %s\n' "$REPO" - printf ' GIT_DIR: %s\n' "$GIT_DIR" - printf ' GIT_ATTRIBUTES: %s\n\n' "$GIT_ATTRIBUTES" - printf ' CIPHER: %s\n' "$current_cipher" - printf ' PASSWORD: %s\n\n' "$current_password" + _display_git_configuration + _display_runtime_configuration printf 'Copy and paste the following command to initialize a cloned repository:\n\n' - printf " transcrypt -c %s -p '%s'\n" "$current_cipher" "$escaped_password" + printf " transcrypt -c '%s' -p '%s' -md '%s' --use-pbkdf2 '%s' -sm '%s' -cs '%s'\n" \ + "$cipher" "$escaped_password" "$digest" "$use_pbkdf2" "$salt_method" "$config_salt" + + #[[ ! $REPO ]] || printf ' GIT_WORK_TREE: %s\n' "$REPO" + #printf ' GIT_DIR: %s\n' "$GIT_DIR" + #printf ' GIT_ATTRIBUTES: %s\n\n' "$GIT_ATTRIBUTES" + #printf ' DIGEST: %s\n' "$current_digest" + #printf ' USE_PBKDF2: %s\n' "$current_use_pbkdf2" + #printf ' SALT_METHOD: %s\n' "$current_salt_method" + #if [[ "$current_salt_method" == "configured" ]]; then + # printf ' CONFIG_SALT: %s\n' "$current_config_salt" + #fi + #printf ' CIPHER: %s\n' "$current_cipher" + #printf ' PASSWORD: %s\n\n' "$current_password" + #printf 'Copy and paste the following command to initialize a cloned repository:\n\n' + #printf " transcrypt -c %s -p '%s' -md '%s' --use_pbkdf2 '%s' -sm '%s'\n" \ + # "$current_cipher" "$escaped_password" "$current_digest" "$current_use_pbkdf2" "$current_salt_method" } # remove transcrypt-related settings from the repository's git config @@ -800,14 +1073,19 @@ export_gpg() { die 1 'GPG recipient key "%s" does not exist' "$gpg_recipient" fi - local current_cipher - current_cipher=$(git config --get --local transcrypt.cipher) - local current_password - current_password=$(git config --get --local transcrypt.password) + _load_transcrypt_config_vars + + #local current_cipher + #current_cipher=$(git config --get --local transcrypt.cipher) + #local current_password + #current_password=$(git config --get --local transcrypt.password) mkdir -p "${CRYPT_DIR}" local gpg_encrypt_cmd="gpg --batch --recipient $gpg_recipient --trust-model always --yes --armor --quiet --encrypt -" - printf 'password=%s\ncipher=%s\n' "$current_password" "$current_cipher" | $gpg_encrypt_cmd >"${CRYPT_DIR}/${gpg_recipient}.asc" + #printf 'password=%s\ncipher=%s\n' "$current_password" "$current_cipher" | $gpg_encrypt_cmd >"${CRYPT_DIR}/${gpg_recipient}.asc" + printf 'password=%s\ncipher=%s\ndigest=%s\nuse_pbkdf2=%s\nsalt_method=%s\nconfig_salt=%s\n\n' \ + "$password" "$cipher" "$digest" "$use_pbkdf2" "$salt_method" "$config_salt" | + $gpg_encrypt_cmd >"${CRYPT_DIR}/${gpg_recipient}.asc" printf "The transcrypt configuration has been encrypted and exported to:\n%s/crypt/%s.asc\n" "$GIT_DIR" "$gpg_recipient" } @@ -840,6 +1118,10 @@ import_gpg() { cipher=$(printf '%s' "$configuration" | grep '^cipher' | cut -d'=' -f 2-) password=$(printf '%s' "$configuration" | grep '^password' | cut -d'=' -f 2-) + digest=$(printf '%s' "$configuration" | grep '^digest' | cut -d'=' -f 2-) + use_pbkdf2=$(printf '%s' "$configuration" | grep '^use_pbkdf2' | cut -d'=' -f 2-) + salt_method=$(printf '%s' "$configuration" | grep '^salt_method' | cut -d'=' -f 2-) + config_salt=$(printf '%s' "$configuration" | grep '^config_salt' | cut -d'=' -f 2-) } # print this script's usage message to stderr @@ -878,6 +1160,22 @@ help() { the password to derive the key from; defaults to 30 random base64 characters + -md, --digest=DIGEST + the digest used to hash the salted password; + defaults to md5 + + -pbkdf2, --use_pbkdf2=USE_PBKDF2 + Use the pbkdf2 openssl encryption feature; + defaults to 0 + + -sm, --salt_method=SALT_METHOD + Method used to compute deterministic salt; can be password or configured + defaults to password + + -cm, --config_salt=CONFIG_SALT + If the salt method is "configured" then force it to use + this salt, otherwise it is randomly initialized. + --set-openssl-path=PATH_TO_OPENSSL use OpenSSL at this path; defaults to 'openssl' in \$PATH @@ -983,6 +1281,10 @@ show_file='' uninstall='' upgrade='' openssl_path='openssl' +use_pbkdf2='' +digest='' +salt_method='' +config_salt='' # used to bypass certain safety checks requires_existing_config='' @@ -1024,6 +1326,37 @@ while [[ "${1:-}" != '' ]]; do --cipher=*) cipher=${1#*=} ;; + -md | --digest) + digest=$2 + shift + ;; + --digest=*) + digest=${1#*=} + ;; + -pbkdf2) + use_pbkdf2=1 + ;; + --use-pbkdf2) + use_pbkdf2=${2} + shift + ;; + --use-pbkdf2=*) + use_pbkdf2=${1#*=} + ;; + -sm | --salt-method) + salt_method=$2 + shift + ;; + --salt-method=*) + salt_method=${1#*=} + ;; + -cs | --config-salt) + config_salt=$2 + shift + ;; + --config-salt=*) + config_salt=${1#*=} + ;; -p | --password) password=$2 shift @@ -1161,8 +1494,23 @@ fi # perform function calls to configure transcrypt get_cipher +get_digest +get_use_pbkdf2 +get_salt_method get_password +if [[ "$salt_method" == "configured" ]]; then + # If the user didnt specify explicitly, try to load the config salt + if [[ "$config_salt" == "" ]]; then + config_salt=$(_load_versioned_config_var "transcrypt.config-salt") + # If we have not configured the config_salt (or we need to rekey), + # then generate new random salt + if [[ "$config_salt" == "" ]] || [[ $rekey ]]; then + config_salt=$(openssl rand -hex 32) + fi + fi +fi + if [[ $rekey ]] && [[ $interactive ]]; then confirm_rekey elif [[ $interactive ]]; then diff --git a/transcrypt_bashlib.sh b/transcrypt_bashlib.sh new file mode 100644 index 0000000..0d588ac --- /dev/null +++ b/transcrypt_bashlib.sh @@ -0,0 +1,451 @@ +#!/usr/bin/env bash +__doc__=' +This contains the standalone heredoc versions of transcrypt library functions. +These are not used in the main executable itself. Instead they are ported from +here to there and stripped of extranious information. + +This makes it easier to unit test the individual bash components of the system +while still providing a fast and reasonably optimized runtime. +' + +# print a message to stderr +warn() { + local fmt="$1" + shift + # shellcheck disable=SC2059 + printf "transcrypt: $fmt\n" "$@" >&2 +} + +# print a message to stderr and exit with either +# the given status or that of the most recent command +die() { + local st="$?" + if [[ "$1" != *[^0-9]* ]]; then + st="$1" + shift + fi + warn "$@" + exit "$st" +} + +# print a canonicalized absolute pathname +realpath() { + local path=$1 + + # make path absolute + local abspath=$path + if [[ -n ${abspath##/*} ]]; then + abspath=$(pwd -P)/$abspath + fi + + # canonicalize path + local dirname= + if [[ -d $abspath ]]; then + dirname=$(cd "$abspath" && pwd -P) + abspath=$dirname + elif [[ -e $abspath ]]; then + dirname=$(cd "${abspath%/*}/" 2>/dev/null && pwd -P) + abspath=$dirname/${abspath##*/} + fi + + if [[ -d $dirname && -e $abspath ]]; then + printf '%s\n' "$abspath" + else + printf 'invalid path: %s\n' "$path" >&2 + exit 1 + fi +} + +joinby(){ + __doc__=' + A function that works similar to a Python join + + Args: + SEP: the separator + *ARR: elements of the strings to join + + Usage: + source $HOME/local/init/utils.sh + ARR=("foo" "bar" "baz") + RESULT=$(joinby / "${ARR[@]}") + echo "RESULT = $RESULT" + + RESULT = foo/bar/baz + + References: + https://stackoverflow.com/questions/1527049/how-can-i-join-elements-of-an-array-in-bash + ' + _handle_help "$@" || return 0 + local d=${1-} f=${2-} + if shift 2; then + printf %s "$f" "${@/#/$d}" + fi +} + +# shellcheck disable=SC2154 +_openssl_encrypt() +{ + __doc__=' + Example: + source ~/code/transcrypt/transcrypt_bashlib.sh + pbkdf2_args=("-pbkdf2") + salt=deadbeafbad00000 + digest=sha256 + password=12345 + openssl_path=openssl + cipher=aes-256-cbc + tempfile=$(mktemp) + echo "secret" > $tempfile + _openssl_encrypt + ' + # Exepcts that the following variables are set: + # password, openssl_path, cipher, digest, salt, pbkdf2_args, tempfile + + # Test the openssl version + openssl_major_version=$($openssl_path version | cut -d' ' -f2 | cut -d'.' -f1) + if [ "$openssl_major_version" -ge "3" ]; then + # OpenSSL 3.x + # In 3.x openssl disabled output of the salt prefix, which we need for determinism. + # To reenable the prefix we emit the raw prefix bytes, encrypt in raw bytes, and then + # send that entire stream to be base64 encoded + (printf "Salted__" && printf "%s" "$salt" | xxd -r -p && \ + ENC_PASS=$password "$openssl_path" enc "-${cipher}" -md "${digest}" -pass env:ENC_PASS -e -S "$salt" "${pbkdf2_args[@]}" -in "$tempfile" + ) | base64 + else + # OpenSSL 1.x + ENC_PASS=$password "$openssl_path" enc "-${cipher}" -md "${digest}" -pass env:ENC_PASS -e -a -S "$salt" "${pbkdf2_args[@]}" -in "$tempfile" + fi +} + +# shellcheck disable=SC2154 +_openssl_decrypt() +{ + __doc__=' + Example: + source ~/code/transcrypt/transcrypt_bashlib.sh + pbkdf2_args=("-pbkdf2") + digest=sha256 + password=12345 + openssl_path=openssl + cipher=aes-256-cbc + echo "U2FsdGVkX1/erb6vutAAADPXEjWJ3l4MEpSGTj5qC/w=" | _openssl_decrypt + tempfile=$(mktemp) + echo "U2FsdGVkX1/erb6vutAAADPXEjWJ3l4MEpSGTj5qC/w=" > $tempfile + _openssl_decrypt -in $tempfile + ' + # Exepcts that the following variables are set: + # password, openssl_path, cipher, digest, pbkdf2_args + # This works the same across openssl versions + ENC_PASS=$password "$openssl_path" enc "-${cipher}" -md "${digest}" -pass env:ENC_PASS "${pbkdf2_args[@]}" -d -a "$@" +} + +_openssl_list(){ + # Args: the openssl commands to list + __doc__=' + source ~/code/transcrypt/bash_helpers.sh + arg=digest-commands + _openssl_list digest-commands + _openssl_list cipher-commands + ' + openssl_path=openssl + arg=$1 + if "${openssl_path} list-$arg" &>/dev/null; then + # OpenSSL < v1.1.0 + "${openssl_path}" "list-$arg" + else + # OpenSSL >= v1.1.0 + "${openssl_path}" "list" "-$arg" + fi +} + + +_is_contained_str(){ + __doc__=' + Args: + arg : the query to check if it is contained in the values + values : a string of space separated values + + Example: + source ~/code/transcrypt/bash_helpers.sh + # Demo using raw call + (_is_contained_str "foo" "foo bar baz" && echo "contained") || echo "missing" + (_is_contained_str "bar" "foo bar baz" && echo "contained") || echo "missing" + (_is_contained_str "baz" "foo bar baz" && echo "contained") || echo "missing" + (_is_contained_str "biz" "foo bar baz" && echo "contained") || echo "missing" + # Demo using variables + arg="bar" + values="foo bar baz" + (_is_contained_str "$arg" "$values" && echo "contained") || echo "missing" + ' + arg=$1 + values=$2 + echo "$values" | tr -s ' ' '\n' | grep -Fx "$arg" &>/dev/null +} + +_is_contained_arr(){ + __doc__=' + Check if the first value is contained the rest of the values + + Args: + arg : the query to check if it is contained in the values + *values : the rest of the arguments are individual elements in the values + + Example: + source ~/code/transcrypt/bash_helpers.sh + # Demo using raw call + (_is_contained_arr "bar" "foo" "bar" "baz" && echo "contained") || echo "missing" + (_is_contained_arr "biz" "foo" "bar" "baz" && echo "contained") || echo "missing" + # Demo using variables + values=("foo" "bar" "baz") + arg="bar" + (_is_contained_arr "$arg" "${values[@]}" && echo "contained") || echo "missing" + arg="biz" + (_is_contained_arr "$arg" "${values[@]}" && echo "contained") || echo "missing" + ' + # The first argument must be equal to one of the subsequent arguments + local arg=$1 + shift + local arr=("$@") + for val in "${arr[@]}"; + do + if [[ "${arg}" == "${val}" ]]; then + return 0 + fi + done + return 1 +} + +_set_global(){ + # sets a bash global variable by name + key=$1 + val=$2 + printf -v "$key" '%s' "$val" +} + +_validate_variable_arr(){ + __doc__=' + Example: + source bash_helpers.sh + foo="bar" + valid_values=("bar" "biz") + _validate_variable "foo" "${valid_values[@]}" + interactive=1 + _validate_variable "blaz" "${valid_values[@]}" + ' + local varname=$1 + local valid_values=$2 + local varval=${!varname} + if ! _is_contained_arr "$varval" "${valid_values[@]}"; then + local valid_values_str + valid_values_str=$(joinby ', ' "${valid_values[@]}") + message=$(printf "%s is %s, but must be one of: %s" "$varname" "$varval" "$valid_values_str") + if [[ $interactive ]]; then + _set_global "$varname" "" + echo "$message" + else + die 1 "$message" + fi + fi +} + + +_validate_variable_str(){ + __doc__=' + Checks if the target variable is in the set of valid values. + If it is not, it unsets the target variable, then if not in interactive + mode it calls die. + + Args: + varname: name of variable to validate + valid_values: space separated string of valid values + + Example: + source bash_helpers.sh + valid_values="bar biz" + foo="bar" + _validate_variable_str "foo" "$valid_values" + interactive=1 + blaz=fds + _validate_variable_str "blaz" "$valid_values" + ' + local varname=$1 + local valid_values=$2 + local varval=${!varname} + if ! _is_contained_str "$varval" "$valid_values"; then + message=$(printf '%s is `%s`, but must be one of: %s' "$varname" "$varval" "$valid_values") + if [[ $interactive ]]; then + _set_global "$varname" "" + echo "$message" + else + die 1 "$message" + fi + fi +} + +_get_user_input2() { + __doc__=' + Helper to prompt the user, store a response, and validate the result + Args: + varname : name of the bash variable to populate + default : the default value to use if the user provides no answer + valid_values: space separated string of valid values + prompt : string to present to the user + + Example: + source ~/code/transcrypt/bash_helpers.sh + interactive=1 + myvar= + echo "myvar = <$myvar>" + _get_user_input2 "myvar" "a" "a b c" "choose one" + ' + local varname=$1 + local default=$2 + local valid_values=$3 + local prompt=$4 + + while [[ ! ${!varname} ]]; do + local answer= + if [[ $interactive ]]; then + printf '%s > ' "$prompt" + read -r answer + fi + # use the default value if the user gave no answer; otherwise call the + # validate function, which should set the varname to empty if it is + # invalid and the user should continue, otherwise it should die. + if [[ ! $answer ]]; then + _set_global "$varname" "$default" + else + _set_global "$varname" "$answer" + _validate_variable_str "$varname" "$valid_values" + fi + done +} + + +# Helper to prompt the user, store a response, and validate the result +_get_user_input() { + local varname=$1 + local default=$2 + local validate_fn=$3 + local prompt=$4 + + while [[ ! ${!varname} ]]; do + local answer= + if [[ $interactive ]]; then + printf '%s' "$prompt" + read -r answer + fi + # use the default value if the user gave no answer; otherwise call the + # validate function, which should set the varname to empty if it is + # invalid and the user should continue, otherwise it should die. + if [[ ! $answer ]]; then + _set_global "$varname" "$default" + else + _set_global "$varname" "$answer" + ${validate_fn} + fi + done +} + + +# Load a config var from the versioned config +# shellcheck disable=SC2155 +_load_versioned_config_var(){ + # the current git repository's top-level directory + if [ -z "${VERSIONED_TC_CONFIG+x}" ]; then + readonly REPO=$(git rev-parse --show-toplevel 2>/dev/null) + # This is the place where transcrypt can store state that will be + # "versioned" (i.e. checked into the repo) + readonly VERSIONED_TC_DIR="${REPO}/.transcrypt" + readonly VERSIONED_TC_CONFIG="${VERSIONED_TC_DIR}/config" + fi + local key=$1 + # Test for blocked variables that should not go into a plaintext config file + if _is_contained_str "${key}" "${VERSIONED_CONFIG_BLOCKLIST}"; then + warn "Cannot use ${key} in versioned the transcrypt config" + return 1 + fi + # https://unix.stackexchange.com/questions/175648/use-config-file-for-my-shell-script + git config --file "${VERSIONED_TC_CONFIG}" --get "${key}" || true +} + +# Write a config var to the versioned config +_set_versioned_config_var(){ + local key=$1 + local val=$2 + # Test for blocked variables that should not go into a plaintext config file + if _is_contained_str "${key}" "${VERSIONED_CONFIG_BLOCKLIST}"; then + warn "Cannot use ${key} in versioned the transcrypt config" + return 1 + fi + mkdir -p "${VERSIONED_TC_DIR}" + git config --file "${VERSIONED_TC_CONFIG}" "${key}" "${val}" +} + +# +_load_config_var(){ + # First try loading from the local checkout-independent .git/config file + # If that doesn't work, then look in the .transcrypt/config file + # (which is expected to be stored in plaintext and checked into the repo) + # Certain values will be blocked from being placed here (like the password) + local key=$1 + git config --get --local "${key}" + if [[ "$?" != "0" ]]; then + _load_versioned_config_var "${key}" + fi +} + +# shellcheck disable=SC2155 +_load_transcrypt_config_vars(){ + # Populate bash vars with our config + cipher=$(git config --get --local transcrypt.cipher) + digest=$(git config --get --local transcrypt.digest) + use_pbkdf2=$(git config --get --local transcrypt.use-pbkdf2) + salt_method=$(git config --get --local transcrypt.salt-method) + + password=$(git config --get --local transcrypt.password) + openssl_path=$(git config --get --local transcrypt.openssl-path) + + if [[ "$salt_method" == "configured" ]]; then + config_salt=$(_load_versioned_config_var "transcrypt.config-salt") + else + config_salt="" + fi +} + +_load_vars_for_encryption(){ + # Helper to populate variables needed to call openssl encryption + _load_transcrypt_config_vars + + if [[ "$use_pbkdf2" == "1" ]]; then + pbkdf2_args=('-pbkdf2') + else + pbkdf2_args=() + fi + + if [[ "$salt_method" == "password" ]]; then + extra_salt=$password + elif [[ "$salt_method" == "configured" ]]; then + extra_salt=$config_salt + else + die "unknown salt method" + fi + + if [[ "$extra_salt" == "" ]]; then + die "Extra salt is not set" + fi +} + +_benchmark_methods(){ + # Development helepr to determine which way of checking if we have a available digest / cipher is fastest + arg="sha512" + source ~/code/transcrypt/bash_helpers.sh + time (openssl list -digest-commands | tr -s ' ' '\n' | grep -Fx "$arg") + echo $? + time _is_contained_str "$arg" "$(openssl list -digest-commands)" + echo $? + time (readarray -t available <<< "$(openssl list -digest-commands | tr -s ' ' '\n')" && _is_contained_arr "$arg" "${available[@]}") + echo $? + #bash_array_repr "${available[@]}" +} +