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/.github/workflows/run-bats-core-tests.yml b/.github/workflows/run-bats-core-tests.yml index 70b9b26..bd326a3 100644 --- a/.github/workflows/run-bats-core-tests.yml +++ b/.github/workflows/run-bats-core-tests.yml @@ -20,7 +20,7 @@ jobs: - name: Run shellcheck and shfmt uses: luizm/action-sh-checker@master with: - sh_checker_exclude: tests + sh_checker_exclude: "tests example" sh_checker_comment: true test: diff --git a/CHANGELOG.md b/CHANGELOG.md index a240578..6f3df84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ 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 +## [Unreleased] + +### Added + +- Add support for pbkdf2 +- Add support for user specified digest +- Add support for new configured base salt +- Add support for an optional .transcrypt versioned directory +- Support for OpenSSL 3.x +- Add support for development editable install + ## [2.2.0] - 2022-07-09 ### Added diff --git a/contrib/bash/transcrypt b/contrib/bash/transcrypt index a1096e4..2474566 100644 --- a/contrib/bash/transcrypt +++ b/contrib/bash/transcrypt @@ -21,18 +21,33 @@ _transcrypt() { COMPREPLY=() cur="${COMP_WORDS[COMP_CWORD]}" prev="${COMP_WORDS[COMP_CWORD-1]}" - opts="-c -p -y -d -r -f -F -u -l -s -e -i -v -h \ - --cipher --password --set-openssl-path --yes --display --rekey --flush-credentials --force --uninstall --upgrade --list --show-raw --export-gpg --import-gpg --version --help" + opts="-c -p -md -k -bs -vc -y -d -r -f -F -u -l -s -e -i -v -h \ + --cipher --password --digest --kdf --base-salt --versioned-config --set-openssl-path --yes --display --rekey --flush-credentials --force --uninstall --upgrade --list --show-raw --export-gpg --import-gpg --version --help" 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 digests=$(openssl list-digest-commands || openssl list -digest-commands &2>/dev/null) + COMPREPLY=( $(compgen -W "${digests}" -- ${cur}) ) + return 0 + ;; + --kdf) + COMPREPLY=( $(compgen -W "none pbkdf2" -- ${cur}) ) + return 0 + ;; + -bs | --base-salt) + return 0 + ;; + -vc | --versioned-config) + return 0 + ;; -s | --show-raw) _files_and_dirs return 0 diff --git a/contrib/packaging/pacman/PKGBUILD b/contrib/packaging/pacman/PKGBUILD index 9556e1f..f9b1ef8 100644 --- a/contrib/packaging/pacman/PKGBUILD +++ b/contrib/packaging/pacman/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Aaron Bull Schaefer pkgname=transcrypt -pkgver=2.2.0 +pkgver=3.0.0-pre 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 7bed4a7..56152dd 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' \ + '(-bs --base-salt -d --display -f --flush-credentials -u --uninstall)'{-bs,--base-salt=}'[specify base-salt]:base-salt' \ + '(-k --kdf -d --display -f --flush-credentials -u --uninstall)'{-k,--kdf=}'[specify kdf]:kdf' \ + '(-vc --versioned-config -d --display -f --flush-credentials -u --uninstall)'{-vc,--versioned-config=}'[specify use-versioned-config]:use-versioned-config' \ '(-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..276a040 --- /dev/null +++ b/docs/algorithm.rst @@ -0,0 +1,263 @@ +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 (similar to) 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 (similar to) the command 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 variables are +required for encryption and decryption: + +* ``cipher`` +* ``password`` +* ``digest`` +* ``kdf`` +* ``base_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 ``kdf`` and ``base_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.kdf`` + +Which can be set to "none" or "pbkdf2", which will enable the ``-pbkdf2`` +openssl flag in the latter case. + +The backwards compatible setting for transcrypt < 3.x is ``--kdf=none``. + +See Also: + +PKCS5#5.2 (RFC-2898) +https://datatracker.ietf.org/doc/html/rfc2898#section-5.2 + +Base Salt +~~~~~~~~~ + +Lastly, there is ``base_salt``, which influences how we determine the final +salt for the encryption process. + +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 "random" 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.kdf`` and ``transcrypt.base-salt``. + +In the case where ``transcrypt.kdf=none``, the "extra-salt" is set +to the user's plaintext password and ``transcrypt.base-salt`` is ignored. 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 "random" method. + +In the case where ``transcrypt.kdf=pbkdf2``, transcrypt will store a randomized +(32 character hex string) or custom user-specified string in +``transcrypt.base-salt``. This value is rerandomized on a rekey. 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. + +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. + +We also introduce an option to disable the versioned config by specifying +``--versioned-config=0`` on the command line. Thus the user can still choose to +keep the chosen cipher, digest, use of pbkdf2, and base-salt a secret if they +desire (although we will remind the reader that +`security by obscurity _` +should never be relied on). diff --git a/example/end_to_end_example.sh b/example/end_to_end_example.sh new file mode 100644 index 0000000..fa5f339 --- /dev/null +++ b/example/end_to_end_example.sh @@ -0,0 +1,53 @@ +#!/bin/bash +__doc__=" +A simple demo of transcrypt +" +set -x + +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/demo-repo-tc-end-to-end +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 a KDF, but an old hash function +transcrypt -c aes-256-cbc -p 'correct horse battery staple' -md MD5 --kdf=pbkdf2 -y +git commit -m "Configured transcrypt" + +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 a new more secure hash function +transcrypt --rekey -c aes-256-cbc -p 'foobar' -md SHA256 -y +git commit -am "changed crypto settings" + + +echo "New secret contents" >> "$DEMO_REPO"/safe/secret_file +git commit -am "added secrets" + +transcrypt -d +transcrypt -f -y diff --git a/example/legacy_upgrade_example.sh b/example/legacy_upgrade_example.sh new file mode 100644 index 0000000..66eb0f3 --- /dev/null +++ b/example/legacy_upgrade_example.sh @@ -0,0 +1,45 @@ +#!/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/demo-tc-repo-upgrade +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 --kdf=0 -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 + +echo "New secret contents" >> "$DEMO_REPO"/safe/secret_file +git commit -am "added secrets" + +transcrypt -d +transcrypt -f -y diff --git a/man/transcrypt.1 b/man/transcrypt.1 index 7b1d979..174d43e 100644 --- a/man/transcrypt.1 +++ b/man/transcrypt.1 @@ -1,118 +1,99 @@ -.\" generated with Ronn/v0.7.3 -.\" http://github.com/rtomayko/ronn/tree/0.7.3 -. -.TH "TRANSCRYPT" "1" "August 2016" "" "" -. +.\" generated with Ronn-NG/v0.9.1 +.\" http://github.com/apjanke/ronn-ng/tree/0.9.1 +.TH "TRANSCRYPT" "1" "July 2022" "" .SH "NAME" \fBtranscrypt\fR \- transparently encrypt files within a git repository -. .SH "SYNOPSIS" -\fBtranscrypt\fR [\fIoptions\fR\.\.\.] -. +\fBtranscrypt\fR [\fIoptions\fR\|\.\|\.\|\.] .SH "DESCRIPTION" -transcrypt will configure a Git repository to support the transparent encryption/decryption of files by utilizing OpenSSL\'s symmetric cipher routines and Git\'s built\-in clean/smudge filters\. It will also add a Git alias "ls\-crypt" to list all transparently encrypted files within the repository\. -. +transcrypt will configure a Git repository to support the transparent encryption/decryption of files by utilizing OpenSSL's symmetric cipher routines and Git's built\-in clean/smudge filters\. It will also add a Git alias "ls\-crypt" to list all transparently encrypted files within the repository\. .P The transcrypt source code and full documentation may be downloaded from \fIhttps://github\.com/elasticdog/transcrypt\fR\. -. .SH "OPTIONS" -. .TP \fB\-c\fR, \fB\-\-cipher\fR=\fIcipher\fR the symmetric cipher to utilize for encryption; defaults to aes\-256\-cbc -. .TP \fB\-p\fR, \fB\-\-password\fR=\fIpassword\fR the password to derive the key from; defaults to 30 random base64 characters -. +.TP +\fB\-md\fR, \fB\-\-digest\fR=\fIdigest\fR +the password to derive the key from; defaults to 30 random base64 characters +.TP +\fB\-\-kdf\fR=\fIkdf\fR +the digest used to hash the salted password; defaults to md5\. It is strongly recommended to use a stronger hash (e\.g\. sha256) if possible\. +.TP +\fB\-bs\fR, \fB\-\-base\-salt\fR=\fIbase_salt\fR +if specified, and a KDF is used, this overrides the text used as the basis for salt\-generation\. +.TP +\fB\-vs\fR, \fB\-\-versioned\-config\fR=\fIuse_versioned_config\fR +force transcrypt to use or not use the versioned config\. By default the versioned config is used when using a KDF\. +.TP +\fB\-\-set\-openssl\-path\fR=\fIpath_to_openssl\fR +use OpenSSL at this path; defaults to 'openssl' in $PATH .TP \fB\-y\fR, \fB\-\-yes\fR assume yes and accept defaults for non\-specified options -. .TP \fB\-d\fR, \fB\-\-display\fR -display the current repository\'s cipher and password -. +display the current repository's cipher and password .TP \fB\-r\fR, \fB\-\-rekey\fR re\-encrypt all encrypted files using new credentials -. .TP \fB\-f\fR, \fB\-\-flush\-credentials\fR remove the locally cached encryption credentials and re\-encrypt any files that had been previously decrypted -. .TP \fB\-F\fR, \fB\-\-force\fR ignore whether the git directory is clean, proceed with the possibility that uncommitted changes are overwritten -. .TP \fB\-u\fR, \fB\-\-uninstall\fR remove all transcrypt configuration from the repository and leave files in the current working copy decrypted -. +.TP +\fB\-\-upgrade\fR +apply the latest transcrypt scripts in the repository without changing your configuration settings .TP \fB\-l\fR, \fB\-\-list\fR list all of the transparently encrypted files in the repository, relative to the top\-level directory -. .TP \fB\-s\fR, \fB\-\-show\-raw\fR=\fIfile\fR show the raw file as stored in the git commit object; use this to check if files are encrypted as expected -. .TP \fB\-e\fR, \fB\-\-export\-gpg\fR=\fIrecipient\fR -export the repository\'s cipher and password to a file encrypted for a gpg recipient -. +export the repository's cipher and password to a file encrypted for a gpg recipient .TP \fB\-i\fR, \fB\-\-import\-gpg\fR=\fIfile\fR import the password and cipher from a gpg encrypted file -. .TP \fB\-v\fR, \fB\-\-version\fR print the version information -. .TP \fB\-h\fR, \fB\-\-help\fR view this help message -. .SH "EXAMPLES" To initialize a Git repository to support transparent encryption, just change into the repo and run the transcrypt script\. transcrypt will prompt you interactively for all required information if the corresponding option flags were not given\. -. .IP "" 4 -. .nf - $ cd / $ transcrypt -. .fi -. .IP "" 0 -. .P -Once a repository has been configured with transcrypt, you can transparently encrypt files by applying the "crypt" filter and diff to a pattern in the top\-level \fI\.gitattributes\fR config\. If that pattern matches a file in your repository, the file will be transparently encrypted once you stage and commit it: -. +Once a repository has been configured with transcrypt, you can transparently encrypt files by applying the "crypt" filter, diff and merge to a pattern in the top\-level \fI\.gitattributes\fR config\. If that pattern matches a file in your repository, the file will be transparently encrypted once you stage and commit it: .IP "" 4 -. .nf - -$ echo \'sensitive_file filter=crypt diff=crypt\' >> \.gitattributes +$ echo 'sensitive_file filter=crypt diff=crypt merge=crypt' >> \.gitattributes $ git add \.gitattributes sensitive_file -$ git commit \-m \'Add encrypted version of a sensitive file\' -. +$ git commit \-m 'Add encrypted version of a sensitive file' .fi -. .IP "" 0 -. .P See the gitattributes(5) man page for more information\. -. .P -If you have just cloned a repository containing files that are encrypted, you\'ll want to configure transcrypt with the same cipher and password as the origin repository\. Once transcrypt has stored the matching credentials, it will force a checkout of any existing encrypted files in order to decrypt them\. -. +If you have just cloned a repository containing files that are encrypted, you'll want to configure transcrypt with the same cipher and password as the origin repository\. Once transcrypt has stored the matching credentials, it will force a checkout of any existing encrypted files in order to decrypt them\. .P If the origin repository has just rekeyed, all clones should flush their transcrypt credentials, fetch and merge the new encrypted files via Git, and then re\-configure transcrypt with the new credentials\. -. .SH "AUTHOR" Aaron Bull Schaefer -. .SH "SEE ALSO" enc(1), gitattributes(5) diff --git a/man/transcrypt.1.ronn b/man/transcrypt.1.ronn index ef0d072..f928193 100644 --- a/man/transcrypt.1.ronn +++ b/man/transcrypt.1.ronn @@ -25,6 +25,23 @@ The transcrypt source code and full documentation may be downloaded from the password to derive the key from; defaults to 30 random base64 characters + * `-md`, `--digest`=: + the password to derive the key from; + defaults to 30 random base64 characters + + * `--kdf`=: + the digest used to hash the salted password; + defaults to md5. It is strongly recommended to use + a stronger hash (e.g. sha256) if possible. + + * `-bs`, `--base-salt`=: + if specified, and a KDF is used, this overrides the text + used as the basis for salt-generation. + + * `-vc`, `--versioned-config`=: + force transcrypt to use or not use the versioned config. + By default the versioned config is used when using a KDF. + * `--set-openssl-path`=: use OpenSSL at this path; defaults to 'openssl' in $PATH 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..4c70b51 --- /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 -k pbkdf2 -p "foobar" -md SHA512 -sm configured + +./transcrypt -d + +transcrypt --uninstall -y diff --git a/tests/oci_container.py b/tests/oci_container.py new file mode 100644 index 0000000..39f057f --- /dev/null +++ b/tests/oci_container.py @@ -0,0 +1,356 @@ +""" +Ported from cibuildwheel: https://github.com/pypa/cibuildwheel/blob/main/cibuildwheel/oci_container.py +""" +from __future__ import annotations + +import io +import json +import os +import itertools +# import platform +import shlex +import shutil +import subprocess +import sys +import uuid +from pathlib import Path, PurePath, PurePosixPath +from types import TracebackType +from typing import IO, Dict, Sequence, cast, Any +Literal, PathOrStr, PopenBytes = Any, Any, Any +ContainerEngine = Any + + +class OCIContainer: + """ + An object that represents a running OCI (e.g. Docker) container. + + Intended for use as a context manager e.g. + `with OCIContainer(image = 'ubuntu') as docker:` + + A bash shell is running in the remote container. When `call()` is invoked, + the command is relayed to the remote shell, and the results are streamed + back to cibuildwheel. + + Args: + volumes (None | List[Tuple[PathLike, PathLike]]): + A list of (external-path, internal-path) tuples + + Example: + >>> image = 'ubuntu:22.04' + >>> # Test the default container + >>> with OCIContainer(image=image) as self: + ... self.call(["echo", "hello world"]) + ... self.call(["cat", "/proc/1/cgroup"]) + ... print(self.debug_info()) + """ + + UTILITY_PYTHON = "python3" + + process: PopenBytes + bash_stdin: IO[bytes] + bash_stdout: IO[bytes] + + def __init__( + self, + *, + image: str, + simulate_32_bit: bool = False, + cwd: PathOrStr | None = None, + volumes=None, + engine: ContainerEngine = "docker", + ): + if not image: + raise ValueError("Must have a non-empty image to run.") + + self.image = image + self.simulate_32_bit = simulate_32_bit + self.cwd = cwd + self.volumes = volumes + self.name: str | None = None + self.engine = engine + + def start(self): + self.name = f"OCIContainer-{uuid.uuid4()}" + network_args = [] + shell_args = ["linux32", "/bin/bash"] if self.simulate_32_bit else ["/bin/bash"] + + volume_argvals = ['{}:{}'.format(v1, v2) for v1, v2 in self.volumes] + volume_argkeys = ['--volume'] * len(volume_argvals) + volume_args = list(itertools.chain(*zip(volume_argkeys, volume_argvals))) + print(f'volume_args={volume_args}') + + subprocess.run( + [ + self.engine, + "create", + f"--name={self.name}", + "--interactive", + *network_args, + *volume_args, + self.image, + *shell_args, + ], + check=True, + ) + + self.process = subprocess.Popen( + [ + self.engine, + "start", + "--attach", + "--interactive", + self.name, + ], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + ) + + assert self.process.stdin and self.process.stdout + self.bash_stdin = self.process.stdin + self.bash_stdout = self.process.stdout + + # run a noop command to block until the container is responding + self.call(["/bin/true"], cwd="/") + + if self.cwd: + # Although `docker create -w` does create the working dir if it + # does not exist, podman does not. There does not seem to be a way + # to setup a workdir for a container running in podman. + self.call(["mkdir", "-p", os.fspath(self.cwd)], cwd="/") + + def stop(self): + self.bash_stdin.write(b"exit 0\n") + self.bash_stdin.flush() + self.process.wait(timeout=30) + self.bash_stdin.close() + self.bash_stdout.close() + + if self.engine == "podman": + # This works around what seems to be a race condition in the podman + # backend. The full reason is not understood. See PR #966 for a + # discussion on possible causes and attempts to remove this line. + # For now, this seems to work "well enough". + self.process.wait() + + assert isinstance(self.name, str) + + subprocess.run( + [self.engine, "rm", "--force", "-v", self.name], + stdout=subprocess.DEVNULL, + check=False, + ) + self.name = None + + def __enter__(self) -> OCIContainer: + self.start() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + self.stop() + + def copy_into(self, from_path: Path, to_path: PurePath) -> None: + # `docker cp` causes 'no space left on device' error when + # a container is running and the host filesystem is + # mounted. https://github.com/moby/moby/issues/38995 + # Use `docker exec` instead. + + if from_path.is_dir(): + self.call(["mkdir", "-p", to_path]) + subprocess.run( + f"tar cf - . | {self.engine} exec -i {self.name} tar --no-same-owner -xC {shell_quote(to_path)} -f -", + shell=True, + check=True, + cwd=from_path, + ) + else: + with subprocess.Popen( + [ + self.engine, + "exec", + "-i", + str(self.name), + "sh", + "-c", + f"cat > {shell_quote(to_path)}", + ], + stdin=subprocess.PIPE, + ) as exec_process: + exec_process.stdin = cast(IO[bytes], exec_process.stdin) + + with open(from_path, "rb") as from_file: + shutil.copyfileobj(from_file, exec_process.stdin) + + exec_process.stdin.close() + exec_process.wait() + + if exec_process.returncode: + raise subprocess.CalledProcessError( + exec_process.returncode, exec_process.args, None, None + ) + + def copy_out(self, from_path: PurePath, to_path: Path) -> None: + # note: we assume from_path is a dir + to_path.mkdir(parents=True, exist_ok=True) + + if self.engine == "podman": + subprocess.run( + [ + self.engine, + "cp", + f"{self.name}:{from_path}/.", + str(to_path), + ], + check=True, + cwd=to_path, + ) + elif self.engine == "docker": + # There is a bug in docker that prevents a simple 'cp' invocation + # from working https://github.com/moby/moby/issues/38995 + command = f"{self.engine} exec -i {self.name} tar -cC {shell_quote(from_path)} -f - . | tar -xf -" + subprocess.run( + command, + shell=True, + check=True, + cwd=to_path, + ) + else: + raise KeyError(self.engine) + + def glob(self, path: PurePosixPath, pattern: str) -> list[PurePosixPath]: + glob_pattern = path.joinpath(pattern) + + path_strings = json.loads( + self.call( + [ + self.UTILITY_PYTHON, + "-c", + f"import sys, json, glob; json.dump(glob.glob({str(glob_pattern)!r}), sys.stdout)", + ], + capture_output=True, + ) + ) + + return [PurePosixPath(p) for p in path_strings] + + def call( + self, + args: Sequence[PathOrStr], + env: dict[str, str] | None = None, + capture_output: bool = False, + cwd: PathOrStr | None = None, + ) -> str: + + if cwd is None: + # Podman does not start the a container in a specific working dir + # so we always need to specify it when making calls. + cwd = self.cwd + + chdir = f"cd {cwd}" if cwd else "" + env_assignments = ( + " ".join(f"{shlex.quote(k)}={shlex.quote(v)}" for k, v in env.items()) + if env is not None + else "" + ) + command = " ".join(shlex.quote(str(a)) for a in args) + end_of_message = str(uuid.uuid4()) + + # log the command we're executing + print(f" + {command}") + + # Write a command to the remote shell. First we change the + # cwd, if that's required. Then, we use the `env` utility to run + # `command` inside the specified environment. We use `env` because it + # can cope with spaces and strange characters in the name or value. + # Finally, the remote shell is told to write a footer - this will show + # up in the output so we know when to stop reading, and will include + # the return code of `command`. + self.bash_stdin.write( + bytes( + f"""( + {chdir} + env {env_assignments} {command} + printf "%04d%s\n" $? {end_of_message} + ) + """, + encoding="utf8", + errors="surrogateescape", + ) + ) + self.bash_stdin.flush() + + if capture_output: + output_io: IO[bytes] = io.BytesIO() + else: + output_io = sys.stdout.buffer + + while True: + line = self.bash_stdout.readline() + + if line.endswith(bytes(end_of_message, encoding="utf8") + b"\n"): + # fmt: off + footer_offset = ( + len(line) + - 1 # newline character + - len(end_of_message) # delimiter + - 4 # 4 return code decimals + ) + # fmt: on + return_code_str = line[footer_offset : footer_offset + 4] + return_code = int(return_code_str) + # add the last line to output, without the footer + output_io.write(line[0:footer_offset]) + break + else: + output_io.write(line) + + if isinstance(output_io, io.BytesIO): + output = str(output_io.getvalue(), encoding="utf8", errors="surrogateescape") + else: + output = "" + + if return_code != 0: + raise subprocess.CalledProcessError(return_code, args, output) + + return output + + def get_environment(self) -> dict[str, str]: + env = json.loads( + self.call( + [ + self.UTILITY_PYTHON, + "-c", + "import sys, json, os; json.dump(os.environ.copy(), sys.stdout)", + ], + capture_output=True, + ) + ) + return cast(Dict[str, str], env) + + def environment_executor(self, command: list[str], environment: dict[str, str]) -> str: + # used as an EnvironmentExecutor to evaluate commands and capture output + return self.call(command, env=environment, capture_output=True) + + def debug_info(self) -> str: + if self.engine == "podman": + command = f"{self.engine} info --debug" + else: + command = f"{self.engine} info" + completed = subprocess.run( + command, + shell=True, + check=True, + cwd=self.cwd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + ) + output = str(completed.stdout, encoding="utf8", errors="surrogateescape") + return output + + +def shell_quote(path: PurePath) -> str: + return shlex.quote(os.fspath(path)) diff --git a/tests/test_init.bats b/tests/test_init.bats index f92b70e..0e94b2f 100755 --- a/tests/test_init.bats +++ b/tests/test_init.bats @@ -60,8 +60,9 @@ SETUP_SKIP_INIT_TRANSCRYPT=1 [ "$status" -eq 0 ] [[ "${output}" = *"The current repository was configured using transcrypt version $VERSION"* ]] [[ "${output}" = *" CIPHER: aes-256-cbc"* ]] + [[ "${output}" = *" DIGEST: md5"* ]] [[ "${output}" = *" PASSWORD: abc 123"* ]] - [[ "${output}" = *" transcrypt -c aes-256-cbc -p 'abc 123'"* ]] + [[ "${output}" = *" transcrypt -c 'aes-256-cbc' -p 'abc 123' -md 'md5' -k 'none' -bs 'password'"* ]] } @test "init: show details for -d" { @@ -72,8 +73,9 @@ SETUP_SKIP_INIT_TRANSCRYPT=1 [ "$status" -eq 0 ] [[ "${output}" = *"The current repository was configured using transcrypt version $VERSION"* ]] [[ "${output}" = *" CIPHER: aes-256-cbc"* ]] + [[ "${output}" = *" DIGEST: md5"* ]] [[ "${output}" = *" PASSWORD: abc 123"* ]] - [[ "${output}" = *" transcrypt -c aes-256-cbc -p 'abc 123'"* ]] + [[ "${output}" = *" transcrypt -c 'aes-256-cbc' -p 'abc 123' -md 'md5' -k 'none' -bs 'password'"* ]] } @test "init: respects core.hooksPath setting" { @@ -89,8 +91,9 @@ SETUP_SKIP_INIT_TRANSCRYPT=1 [ "$status" -eq 0 ] [[ "${output}" = *"The current repository was configured using transcrypt version $VERSION"* ]] [[ "${output}" = *" CIPHER: aes-256-cbc"* ]] + [[ "${output}" = *" DIGEST: md5"* ]] [[ "${output}" = *" PASSWORD: abc 123"* ]] - [[ "${output}" = *" transcrypt -c aes-256-cbc -p 'abc 123'"* ]] + [[ "${output}" = *" transcrypt -c 'aes-256-cbc' -p 'abc 123' -md 'md5' -k 'none' -bs 'password'"* ]] } @test "init: transcrypt.openssl-path config setting defaults to 'openssl'" { diff --git a/tests/test_multi_docker.py b/tests/test_multi_docker.py new file mode 100644 index 0000000..c973682 --- /dev/null +++ b/tests/test_multi_docker.py @@ -0,0 +1,106 @@ +def main(): + """ + We are going to setup two docker images, execute transcrypt in each, and + then make sure working on multiple machines with different openssl verions + is handled gracefully + """ + import ubelt as ub + import os + import sys + # Hard coded + transcrypt_repo = ub.Path('$HOME/code/transcrypt').expand() + # Hack sys.path + sys.path.append(os.fspath(transcrypt_repo / 'tests')) + + test_dpath = ub.Path.appdir('transcrypt/tests/') + unshared_dpath1 = (test_dpath / 'unshared_container1').ensuredir() + unshared_dpath2 = (test_dpath / 'unshared_container2').ensuredir() + shared_dpath = (test_dpath / 'shared').ensuredir() + + transcrypt_repo + + from oci_container import OCIContainer + from shlex import split as shsplit + + container1 = OCIContainer( + image='ubuntu:20.04', + volumes=[ + (transcrypt_repo, '/transcrypt'), + (unshared_dpath1, '/unshared'), + (shared_dpath, '/shared'), + ]) + + container2 = OCIContainer( + image='ubuntu:22.04', + volumes=[ + (transcrypt_repo, '/transcrypt'), + (unshared_dpath2, '/unshared'), + (shared_dpath, '/shared'), + ]) + + container1.start() + container2.start() + try: + + container1.call(['/bin/ls'], cwd='/unshared') + container2.call(['/bin/ls'], cwd='/unshared') + container1.call(['/bin/ls'], cwd='/shared') + container2.call(['/bin/ls'], cwd='/shared') + + def setup_container(container): + container.call(shsplit('apt-get update -y')) + container.call(shsplit('apt-get install git bsdmainutils xxd -y')) + + container.call(shsplit(f'git config --global user.email "{container.name}@test.com"')) + container.call(shsplit(f'git config --global user.name "{container.name}"')) + container.call(shsplit('git config --global init.defaultBranch "main"')) + + container.call(shsplit('ls /transcrypt')) + container.call(shsplit('mkdir -p /repos')) + container.call(shsplit('git clone /transcrypt/.git'), cwd='/repos') + container.call(shsplit('chmod +x /repos/transcrypt/transcrypt'), cwd='/repos') + container.call(shsplit('ls -al /repos/transcrypt'), cwd='/repos') + container.call(shsplit('ln -s /repos/transcrypt/transcrypt /usr/local/bin/transcrypt'), cwd='/repos') + container.call(shsplit('transcrypt --version'), cwd='/repos/transcrypt/example') + + container.call(shsplit('git status'), cwd='/repos/transcrypt') + container.call(shsplit('git pull'), cwd='/repos/transcrypt') + container.call(shsplit('bash end_to_end_example.sh'), cwd='/repos/transcrypt/example') + + container.call(shsplit('ls -al /root/tmp/transcrypt-demo/demo-repo-tc-end-to-end')) + container.call(shsplit('git clone /root/tmp/transcrypt-demo/demo-repo-tc-end-to-end/.git'), cwd='/unshared') + + # Ensure both containers have prereqs and run the end-to-end example on + # them to initialize a simple repo. + + # container = container1 + setup_container(container1) + setup_container(container2) + + # Setup one encrypted repo that the containers will both communicate with + container1.call(shsplit('git clone /root/tmp/transcrypt-demo/demo-repo-tc-end-to-end/.git /shared/encrypted'), cwd='/shared') + container1.call(shsplit('git clone /shared/encrypted/.git /shared/decrypted1'), cwd='/shared') + container2.call(shsplit('git clone /shared/encrypted/.git /shared/decrypted2'), cwd='/shared') + + # Decrypt in each one respectively + container1.call(shsplit("transcrypt -c aes-256-cbc -p 'correct horse battery staple' -md MD5 --kdf=pbkdf2 -y"), cwd='/shared/decrypted1') + # Strange. This seems to modify .transcrypt/config for some reason, and + # I don't know why or at least it says it does? It doesn't have a diff, + # but it is modified in the git status, but it's not even an encrypted + # file marked by gitattributes. + container1.call(shsplit("transcrypt -d"), cwd='/shared/decrypted1') + container1.call(shsplit("git status"), cwd='/shared/decrypted1') + container1.call(shsplit("git diff"), cwd='/shared/decrypted1') + + container2.call(shsplit("transcrypt -F -f -y"), cwd='/shared/decrypted2') + container2.call(shsplit("transcrypt -c aes-256-cbc -p 'correct horse battery staple' -md MD5 --kdf=pbkdf2 -y -bs 87c9f716d79a6a96bf84a5475b77c998"), cwd='/shared/decrypted2') + container2.call(shsplit("transcrypt -d"), cwd='/shared/decrypted2') + container2.call(shsplit("git status"), cwd='/shared/decrypted2') + container2.call(shsplit("git diff"), cwd='/shared/decrypted2') + container2.call(shsplit("cat safe/secret_file"), cwd='/shared/decrypted2') + + # container1.call(shsplit("cat safe/secret_file"), cwd='/shared/decrypted1') + + finally: + container1.stop() + container2.stop() diff --git a/tests/test_transcrypt.py b/tests/test_transcrypt.py new file mode 100644 index 0000000..29705e7 --- /dev/null +++ b/tests/test_transcrypt.py @@ -0,0 +1,713 @@ +""" +This file provides a Python wrapper around the transcrypt API for the purpose +of testing. + +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: + >>> from test_transcrypt import * # NOQA + >>> sandbox = DemoSandbox(verbose=1, dpath='special:cache').setup() + >>> config = {'digest': 'sha256', + >>> 'kdf': 'pbkdf2', + >>> 'base_salt': '665896be121e1a0a4a7b18f01780061'} + >>> 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 + >>> raw_texts = [self.show_raw(p) for p in [secret_fpath1, secret_fpath2, secret_fpath3]] + >>> print('raw_texts = {}'.format(ub.repr2(raw_texts, nl=1))) + >>> assert raw_texts == [ + >>> 'U2FsdGVkX18147KP5UmqOFywveuOGf4hCwrWpfJDp3Ah0HHbFPEGdJE0kM4npWzI', + >>> 'U2FsdGVkX183LEAwwnJ0ne/OKU5VANJsOqCA92Oi9hVkKHIwZYiCgJOoedoShPj7', + >>> 'U2FsdGVkX1/NdLm6twCdF3xYLPCfXacDNsHEeGq0UBC1fwTlJKnN2KmPysS/ylPj', + >>> ] + """ + default_config = { + 'cipher': 'aes-256-cbc', + 'password': None, + 'digest': 'md5', + 'kdf': 'none', + 'base_salt': 'password', + 'use_versioned_config': None, + } + + 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): + flags_and_keys = [ + ('-c', 'cipher'), + ('-p', 'password'), + ('-md', 'digest'), + ('--kdf', 'kdf'), + ('-bs', 'base_salt'), + ('-vc', 'use_versioned_config'), + ] + args = [] + for flag, key in flags_and_keys: + value = self.config.get(key, None) + if value is not None: + args.append(flag) + args.append(value) + 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) + self.config['base_salt'] = self._load_unversioned_config()['base_salt'] + + 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) + self.config['base_salt'] = self._load_unversioned_config()['base_salt'] + + 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, + verbose=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_unversioned_config(self): + if self.verbose > 0: + print('Loading unversioned config') + local_config = { + 'use_versioned_config': self._cmd('git config --get --local transcrypt.use-versioned-config', verbose=0)['out'].strip(), + 'cipher': self._cmd('git config --get --local transcrypt.cipher', verbose=0)['out'].strip(), + 'digest': self._cmd('git config --get --local transcrypt.digest', verbose=0)['out'].strip(), + 'kdf': self._cmd('git config --get --local transcrypt.kdf', verbose=0)['out'].strip(), + 'base_salt': self._cmd('git config --get --local transcrypt.base-salt', verbose=0)['out'].strip(), + 'password': self._cmd('git config --get --local transcrypt.password', verbose=0)['out'].strip(), + 'openssl_path': self._cmd('git config --get --local transcrypt.openssl-path', verbose=0)['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() + if self.verbose > 1: + self._show_manual_env_setup() + 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) + if self.verbose: + pass + + 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 _show_manual_env_setup(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 + + xdoctest -m tests/test_transcrypt.py TestCases + + Example: + >>> from test_transcrypt import * # NOQA + >>> self = TestCases(verbose=2) + >>> self.setup() + >>> self.sandbox._show_manual_env_setup() + >>> self.test_round_trip() + >>> self.test_export_gpg() + """ + + 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) + + if 0: + print(self.sandbox.git.status()) + self.sandbox.git.diff() + + 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() + # FIXME + is_ok = got_config == config + if not is_ok: + is_ok = all([got_config[k] == config[k] for k in {'digest', 'password', 'cipher', 'kdf'}]) + + 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', + 'kdf': 'pbkdf2', + 'base_salt': 'random', + } + 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', + 'kdf': 'none', + 'base_salt': '', + } + 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', + 'kdf': 'pbkdf2', + 'base_salt': 'random', + } + 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(): + """ + CommandLine: + xdoctest -m tests/test_transcrypt.py test_configured_salt_changes_on_rekey + """ + config = { + 'cipher': 'aes-256-cbc', + 'password': 'correct horse battery staple', + 'digest': 'sha512', + 'kdf': 'pbkdf2', + 'base_salt': 'random', + } + verbose = 2 + self = TestCases(config=config, verbose=verbose) + self.setup() + before_config = self.tc._load_unversioned_config() + self.tc.rekey({'password': '12345', 'base_salt': ''}) + self.sandbox.git.commit('-am commit rekey') + after_config = self.tc._load_unversioned_config() + assert before_config['password'] != after_config['password'], 'password should have changed!' + assert before_config['base_salt'] != after_config['base_salt'], 'salt should have changed!' + assert before_config['cipher'] == after_config['cipher'] + assert before_config['kdf'] == after_config['kdf'] + assert before_config['openssl_path'] == after_config['openssl_path'] + + +def test_unspecified_salt_without_kdf(): + """ + In this case the salt should default to the password method + """ + config = { + 'cipher': 'aes-256-cbc', + 'password': 'correct horse battery staple', + 'digest': 'sha512', + 'kdf': '', + 'base_salt': None, + } + verbose = 2 + self = TestCases(config=config, verbose=verbose) + self.setup() + config1 = self.tc._load_unversioned_config() + assert config1['base_salt'] == 'password' + + +def test_unspecified_salt_with_kdf(): + config = { + 'cipher': 'aes-256-cbc', + 'password': 'correct horse battery staple', + 'digest': 'sha512', + 'kdf': 'pbkdf2', + 'base_salt': None, + } + verbose = 1 + self = TestCases(config=config, verbose=verbose) + self.setup() + config1 = self.tc._load_unversioned_config() + assert len(config1['base_salt']) == 64 + + +def test_legacy_settings_dont_use_the_versioned_config(): + config = { + 'cipher': 'aes-256-cbc', + 'password': 'correct horse battery staple', + 'digest': 'md5', + 'kdf': 'none', + 'base_salt': None, + } + verbose = 1 + self = TestCases(config=config, verbose=verbose) + self.setup() + config1 = self.tc._load_unversioned_config() + assert not (self.sandbox.dpath / '.transcrypt').exists() + assert config1['use_versioned_config'] == '0' + + +def test_pbkdf_does_use_the_versioned_config(): + config = { + 'cipher': 'aes-256-cbc', + 'password': 'correct horse battery staple', + 'digest': 'md5', + 'kdf': 'pbkdf2', + 'base_salt': None, + } + verbose = 1 + self = TestCases(config=config, verbose=verbose) + self.setup() + config1 = self.tc._load_unversioned_config() + assert config1['use_versioned_config'] == '1' + assert not (self.sandbox.dpath / '.transcrypt').exists() + + +def test_force_use_versioned_config_1(): + config = { + 'cipher': 'aes-256-cbc', + 'password': 'correct horse battery staple', + 'digest': 'md5', + 'kdf': 'none', + 'base_salt': None, + 'use_versioned_config': '1', + } + verbose = 1 + self = TestCases(config=config, verbose=verbose) + self.setup() + config1 = self.tc._load_unversioned_config() + assert config1['use_versioned_config'] == '1' + assert not (self.sandbox.dpath / '.transcrypt').exists() + + +def test_force_use_versioned_config_0(): + config = { + 'cipher': 'aes-256-cbc', + 'password': 'correct horse battery staple', + 'digest': 'md5', + 'kdf': 'pbkdf2', + 'base_salt': None, + 'use_versioned_config': '0', + } + verbose = 1 + self = TestCases(config=config, verbose=verbose) + self.setup() + config1 = self.tc._load_unversioned_config() + assert not (self.sandbox.dpath / '.transcrypt').exists() + assert config1['use_versioned_config'] == '0' + + +def test_salt_changes_when_kdf_changes(): + config = { + 'cipher': 'aes-256-cbc', + 'password': 'correct horse battery staple', + 'digest': 'sha512', + 'kdf': '', + 'base_salt': None, + } + verbose = 2 + self = TestCases(config=config, verbose=verbose) + self.setup() + config1 = self.tc._load_unversioned_config() + assert config1['base_salt'] == 'password' + # Test rekey, base-salt should still be password + self.tc.rekey({'password': '12345'}) + config2 = self.tc._load_unversioned_config() + assert config2['base_salt'] == 'password' + self.sandbox.git.commit('-am commit rekey') + + # Test rekey with kdf=pbkdf2 base-salt should now randomize + self.tc.rekey({'password': '12345', 'kdf': 'pbkdf2', 'base_salt': None}) + config3 = self.tc._load_unversioned_config() + assert len(config3['base_salt']) == 64, 'should have had new random salt' + self.sandbox.git.commit('-am commit rekey') + + # Test rekey going back to no kdf + self.tc.rekey({'password': '12345', 'kdf': 'none', 'base_salt': None}) + config4 = self.tc._load_unversioned_config() + assert config4['base_salt'] == 'password' + + +def test_unsupported_kdf(): + config = { + 'cipher': 'aes-256-cbc', + 'password': 'correct horse battery staple', + 'digest': 'sha512', + 'kdf': 'MY_FAVORITE_UNSUPPORTED_KDF', + 'base_salt': None, + } + verbose = 2 + self = TestCases(config=config, verbose=verbose) + import subprocess + import pytest + with pytest.raises(subprocess.CalledProcessError): + self.setup() + + +def test_kdf_setting_preserved_on_rekey(): + config = { + 'cipher': 'aes-256-cbc', + 'password': 'correct horse battery staple', + 'digest': 'md5', + 'kdf': 'pbkdf2', + 'base_salt': None, + } + verbose = 1 + self = TestCases(config=config, verbose=verbose) + self.setup() + config1 = self.tc._load_unversioned_config() + assert len(config1['base_salt']) == 64 + + # Explicitly don't pass kdf or base salt. + # Transcrypt should reuse the existing kdf setting (but the salt should + # change) + self.tc.rekey({'kdf': None, 'base_salt': None, 'digest': 'SHA512'}) + config2 = self.tc._load_unversioned_config() + assert config2['kdf'] == 'pbkdf2' + assert config1['base_salt'] != config2['base_salt'] + assert len(config1['base_salt']) == 64 + assert len(config2['base_salt']) == 64 + + +def test_configuration_grid(): + """ + CommandLine: + xdoctest -m tests/test_transcrypt.py test_configuration_grid + """ + # 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'], + 'kdf': ['none', 'pbkdf2'], + 'base_salt': ['password', 'random', 'mylittlecustomsalt', None], + 'use_versioned_config': ['0', '1', None], + } + + test_grid = list(ub.named_product(basis)) + + def validate_test_grid(params): + if params['kdf'] == 'none' and params['base_salt'] != 'password': + return False + if params['kdf'] != 'none' and params['base_salt'] == 'password': + return False + return True + + # Remove invalid configs + valid_test_grid = list(filter(validate_test_grid, test_grid)) + print('valid_test_grid = {}'.format(ub.repr2(valid_test_grid, sort=0, nl=1))) + + verbose = 2 + dpath = 'special:temp' + dpath = 'special:cache' + for params in ub.ProgIter(valid_test_grid, desc='test configs', freq=1, + verbose=verbose + 1): + if verbose: + print('\n\n') + print('=================') + print('params = {}'.format(ub.repr2(params, nl=1))) + print('=================') + + config = params.copy() + self = TestCases(config=config, dpath=dpath, verbose=verbose) + self.setup() + self.test_round_trip() + self.test_export_gpg() + self.test_rekey() + if verbose: + print('=================') + +if __name__ == '__main__': + """ + CommandLine: + python tests/test_transcrypt.py + + # Runs everything + pytest tests/test_transcrypt.py -v -s + """ + 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 b392e2d..d63bccd 100755 --- a/transcrypt +++ b/transcrypt @@ -1,5 +1,12 @@ #!/usr/bin/env bash -set -euo pipefail +if [[ ${BASH_SOURCE[0]} == "$0" ]]; then + # Running as a script + set -euo pipefail +fi + +if [[ "${TRANSCRYPT_TRACE+x}" != "" ]]; then + set -x +fi # # transcrypt - https://github.com/elasticdog/transcrypt @@ -16,10 +23,20 @@ set -euo pipefail ##### CONSTANTS # the release version of this script -readonly VERSION='2.2.0' +readonly VERSION='3.0.0-pre' # the default cipher to utilize readonly DEFAULT_CIPHER='aes-256-cbc' +readonly DEFAULT_DIGEST='md5' +readonly DEFAULT_KDF='none' + +# These are config variables we do not allow to be used in the versioned +# configuration +readonly VERSIONED_CONFIG_BLOCKLIST="transcrypt.password transcrypt.openssl-path transcrypt.use-versioned-config" + +# Set to 1 to enable a development editable installation +# This will symlink transcrypt into the .git folder instead of copying it +readonly EDITABLE_INSTALL=${TRANSCRYPT_EDITABLE_INSTALL:=0} ##### FUNCTIONS @@ -51,6 +68,212 @@ realpath() { fi } +_openssl_encrypt() { + # In 3.x openssl disabled output of the salt prefix, which we need for determinism. + # We take control over outputting the prefix 'Salted__' with the salt + # to ensure it is always included regardless of the OpenSSL version. #133 + openssl_major_version=$($openssl_path version | cut -d' ' -f2 | cut -d'.' -f1) + if [ "$openssl_major_version" -ge "3" ]; then + # Encrypt the file to base64, ensuring it includes the prefix 'Salted__' with the salt. #133 + ( + printf "Salted__" && printf "%s" "$final_salt" | xxd -r -p && + # Encrypt file to binary ciphertext + ENC_PASS=$password "$openssl_path" enc -e "-${cipher}" -md "${digest}" -pass env:ENC_PASS -S "$final_salt" ${pbkdf2_arg:+"$pbkdf2_arg"} -in "$tempfile" + ) | + openssl base64 + else + # Encrypt file to base64 ciphertext + ENC_PASS=$password "$openssl_path" enc -e -a "-${cipher}" -md "${digest}" -pass env:ENC_PASS -S "$final_salt" ${pbkdf2_arg:+"$pbkdf2_arg"} -in "$tempfile" + fi +} + +_openssl_decrypt() { + # Expects that the following variables are set: + # password, openssl_path, cipher, digest, pbkdf2_arg + # This works the same across openssl versions + ENC_PASS=$password "$openssl_path" enc -d "-${cipher}" -md "${digest}" -pass env:ENC_PASS -a ${pbkdf2_arg:+"$pbkdf2_arg"} "$@" +} + +# 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" + return 1 + 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 + ${validate_fn} || die "Invalid setting" +} + +# 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}" +} + +# 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}" +} + +_set_unversioned_config_var() { + local key=$1 + local val=$2 + git config "${key}" "${val}" +} + +_load_unversioned_config_var() { + local key=$1 + git config --get --local "${key}" +} + +# shellcheck disable=SC2181 +_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 + _load_unversioned_config_var "${key}" || + ([[ "$use_versioned_config" == "1" ]] && _load_versioned_config_var "${key}") +} + +_set_config_var() { + # Write to both configs + local key=$1 + local val=$2 + _set_unversioned_config_var "$key" "$val" + if [[ "$use_versioned_config" == "1" ]]; then + _set_versioned_config_var "$key" "$val" + fi +} + +# shellcheck disable=SC2155 +_load_transcrypt_config_vars() { + # Populate bash vars with our config + use_versioned_config=$(_load_unversioned_config_var "transcrypt.use-versioned-config") || use_versioned_config=1 + cipher=$(_load_config_var "transcrypt.cipher") || (echo "failed to load transcrypt.cipher" && false) + digest=$(_load_config_var "transcrypt.digest") || (echo "failed to load transcrypt.digest" && false) + kdf=$(_load_config_var "transcrypt.kdf") || (echo "failed to load transcrypt.kdf" && false) + base_salt=$(_load_config_var "transcrypt.base-salt") || (echo "failed to load transcrypt.base-salt" && false) + openssl_path=$(_load_config_var "transcrypt.openssl-path") || (echo "failed to load transcrypt.openssl-path" && false) + password=$(_load_unversioned_config_var transcrypt.password) || (echo "failed to load transcrypt.password" && false) + + #+---------------+-------------+--------------------+------------------+ + #| Action | Using_KDF | BaseSaltInConfig | Outcome | + #|---------------+-------------+--------------------+------------------| + #| helper-script | True | True | Use Config Value | + #| helper-script | True | False | Error | + #| helper-script | False | True | Ignored | + #| helper-script | False | False | Ignored | + #+---------------+-------------+--------------------+------------------+ + + validate_kdf || die "invalid value of kdf in config" + validate_digest || die "invalid value of digest in config" +} + +_load_vars_for_encryption() { + # Helper to populate variables needed to call openssl encryption + _load_transcrypt_config_vars + + if [[ "$kdf" == "pbkdf2" ]]; then + pbkdf2_arg='-pbkdf2' + fi + + if [[ "$base_salt" == "password" ]]; then + extra_salt=$password + elif [[ "$base_salt" == "" ]]; then + die "salt must be specified" + else + extra_salt=$base_salt + 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 +313,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,65 +346,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) - - openssl_major_version=$($openssl_path version | cut -d' ' -f2 | cut -d'.' -f1) - if [ "$openssl_major_version" -ge "3" ]; then - # Encrypt the file to base64, ensuring it includes the prefix 'Salted__' with the salt. #133 - ( - echo -n "Salted__" && echo -n "$salt" | xxd -r -p && - # Encrypt file to binary ciphertext - ENC_PASS=$password "$openssl_path" enc -e "-${cipher}" -md MD5 -pass env:ENC_PASS -S "$salt" -in "$tempfile" - ) | - openssl base64 - else - # Encrypt file to base64 ciphertext - ENC_PASS=$password "$openssl_path" enc -e -a "-${cipher}" -md MD5 -pass env:ENC_PASS -S "$salt" -in "$tempfile" - fi + _load_vars_for_encryption + # NOTE: the openssl standard for salt is 16 hex bytes. + final_salt=$("$openssl_path" dgst -hmac "${filename}:${extra_salt}" -sha256 "$tempfile" | 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 -d "-${cipher}" -md MD5 -pass env:ENC_PASS -a 2>/dev/null || cat "$tempfile" + _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 -d "-${cipher}" -md MD5 -pass env:ENC_PASS -a -in "$filename" 2>/dev/null || cat "$filename" + _load_transcrypt_config_vars + _load_vars_for_encryption + _openssl_decrypt -in "$filename" 2>/dev/null || cat "$filename" } # shellcheck disable=SC2005,SC2002,SC2181 @@ -236,6 +465,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 @@ -330,48 +560,164 @@ 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" + local valid_ciphers + valid_ciphers=$(_openssl_list cipher-commands) + cipher=$(echo "${cipher}" | tr '[:upper:]' '[:lower:]') + _validate_variable_str "cipher" "$valid_ciphers" +} + +validate_digest() { + local valid_digests + valid_digests=$(_openssl_list digest-commands) + digest=$(echo "${digest}" | tr '[:upper:]' '[:lower:]') + _validate_variable_str "digest" "$valid_digests" +} + +validate_kdf() { + # Normalize kdf + kdf=$(echo "${kdf}" | tr '[:upper:]' '[:lower:]') + + # References: + # https://wiki.openssl.org/index.php/EVP_Key_Derivation + openssl_version=$($openssl_path version | cut -d' ' -f2) + openssl_major_version=$(echo "$openssl_version" | cut -d'.' -f1) + openssl_minor_version=$(echo "$openssl_version" | cut -d'.' -f2) + if [ "$openssl_major_version" -ge "3" ]; then + # If openssl is >= 3 we can list kdf algos, via _openssl_list kdf-algorithms + # however the output format is a pain to parse in bash, so we hard code + # known KDFs that we support + # + # Note: it is not straightforward on how to pass these to openssl enc + # except for the case of pbkdf2, disable others until we figure it out + #valid_kdfs="none pbkdf2 scrypt" + valid_kdfs="none pbkdf2" + else + if [ "$openssl_minor_version" -ge "1" ]; then + # In 1.1.x pbkdf2 is the only kdf available + valid_kdfs="none pbkdf2" + else + # In <1.0.x there are no kdfs available + valid_kdfs="none" + fi + fi + _validate_variable_str "kdf" "$valid_kdfs" +} + +validate_base_salt() { + if [[ "$kdf" != "none" ]]; then + if [[ "$base_salt" == "" ]]; then + die 1 'The base salt is required when using a kdf, but it is unspecified.' + elif [[ "$base_salt" == "random" ]]; then + die 1 'The base=salt is set to random, but it has not been randomized'. + elif [[ "$base_salt" == "password" ]]; then + die 1 'Using base-salt="password" method with a KDF is insecure. Use "random" instead'. + fi fi +} - 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='' +get_use_versioned_config() { + #If the user did not specify to use the versioned config or not, then: + # 1. Check if there is a setting in the unversioned config. + # 2. If not, check if the versioned config exists. + # 3. If not, then defer further checking until we know our setting of base-salt. + if [[ "$use_versioned_config" == "" ]]; then + use_versioned_config=$(_load_unversioned_config_var "transcrypt.use-versioned-config" || echo "") + fi + if [[ "$use_versioned_config" == "" ]]; then + if [ -f "${VERSIONED_TC_CONFIG}" ]; then + use_versioned_config="1" else - # shellcheck disable=SC2016 - die 1 '"%s" is not a valid cipher; see `%s`' "$cipher" "$list_cipher_commands" + use_versioned_config="defer" # defer to after base-salt setting is read fi fi } +# ensure we have a digest to hash the salted password +get_digest() { + local prompt + prompt=$(printf 'Encrypt using which digest? [%s] ' "$DEFAULT_DIGEST") + if [[ "$digest" == "" ]]; then + digest=$(_load_config_var "transcrypt.digest" || echo "") + fi + _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") + if [[ "$cipher" == "" ]]; then + cipher=$(_load_config_var "transcrypt.cipher" || echo "") + fi + _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 +get_kdf() { + local prompt + prompt=$(printf 'Which key derivation function? [%s] ' "$DEFAULT_KDF") + if [[ "$kdf" == "" ]]; then + kdf=$(_load_config_var "transcrypt.kdf" || echo "") + fi + _get_user_input kdf "$DEFAULT_KDF" "validate_kdf" "$prompt" +} + +get_base_salt() { + #+-----------+-------------+------------------+--------------------+------------------------+ + #| Action | Using_KDF | BaseSaltInArgv | BaseSaltInConfig | Outcome | + #|-----------+-------------+------------------+--------------------+------------------------| + #| configure | True | True | True | Use Argv and Overwrite | + #| configure | True | True | False | Use Argv and Overwrite | + #| configure | True | False | True | Use Config Value | + #| configure | True | False | False | Randomize | + #| configure | False | True | True | Ignored | + #| configure | False | True | False | Ignored | + #| configure | False | False | True | Ignored | + #| configure | False | False | False | Ignored | + #+-----------+-------------+------------------+--------------------+------------------------+ + #+----------+-------------+------------------+--------------------+------------------------+ + #| Action | Using_KDF | BaseSaltInArgv | BaseSaltInConfig | Outcome | + #|----------+-------------+------------------+--------------------+------------------------| + #| rekey | True | True | True | Use Argv and Overwrite | + #| rekey | True | True | False | Use Argv and Overwrite | + #| rekey | True | False | True | Randomize | + #| rekey | True | False | False | Randomize | + #| rekey | False | True | True | Ignored | + #| rekey | False | True | False | Ignored | + #| rekey | False | False | True | Ignored | + #| rekey | False | False | False | Ignored | + #+----------+-------------+------------------+--------------------+------------------------+ + # When calling `get_base_salt`, the action is always configure or rekey. + if [[ "$kdf" == "none" ]]; then + if [[ "$base_salt" == "" ]]; then + base_salt="password" + fi + if [[ "$use_versioned_config" == "defer" ]]; then + # Handle deferred use_version_config + use_versioned_config="0" + fi + else + if [[ "$base_salt" == "" ]]; then + # The user did not specify a base salt. + if [[ $rekey ]]; then + base_salt="random" + else + base_salt=$(_load_config_var "transcrypt.base-salt" || echo "random") + fi else - cipher=$answer - validate_cipher + # The user specified something for base-salt + if [[ "$base_salt" == "password" ]]; then + die 1 'Using the "password" method with a KDF is insecure. Use "random" instead'. + fi fi - done + # Randomize the base salt if requested. + if [[ "$base_salt" == "random" ]]; then + base_salt=$(openssl rand -hex 16) + fi + if [[ "$use_versioned_config" == "defer" ]]; then + # Handle deferred use_version_config + use_versioned_config="1" + fi + fi } # ensure we have a password to encrypt with @@ -403,13 +749,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 @@ -426,13 +768,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] ' @@ -469,8 +807,13 @@ 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 + rm -f "${CRYPT_DIR}/transcrypt" # unlink if it already exist + cp -f "$current_transcrypt" "${CRYPT_DIR}/transcrypt" + fi # make scripts executable for script in {transcrypt,}; do chmod 0755 "${CRYPT_DIR}/${script}" @@ -503,16 +846,35 @@ 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.cipher "$cipher" - git config transcrypt.password "$password" - git config transcrypt.openssl-path "$openssl_path" + _set_unversioned_config_var "transcrypt.use-versioned-config" "$use_versioned_config" + _set_config_var "transcrypt.version" "$VERSION" + _set_config_var "transcrypt.cipher" "$cipher" + _set_config_var "transcrypt.digest" "$digest" + _set_config_var "transcrypt.kdf" "$kdf" + _set_config_var "transcrypt.base-salt" "$base_salt" + _set_unversioned_config_var "transcrypt.openssl-path" "$openssl_path" + _set_unversioned_config_var "transcrypt.password" "$password" + + if [[ "$use_versioned_config" == "1" ]]; then + if [ -f $RELATIVE_VERSIONED_TC_CONFIG ]; then + 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 + fi # write the filter settings. Sorry for the horrific quote escaping below... # shellcheck disable=SC2016 @@ -533,23 +895,49 @@ 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() { + if [[ "$kdf" == "none" ]]; then + # KDF is not configured, no need to show KDF or base salt config + printf ' CIPHER: %s\n' "$cipher" + printf ' DIGEST: %s\n' "$digest" + printf ' PASSWORD: %s\n' "$password" + else + # KDF is configured, show KDF and base salt config + printf ' CIPHER: %s\n' "$cipher" + printf ' DIGEST: %s\n' "$digest" + printf ' KDF: %s\n' "$kdf" + printf ' PASSWORD: %s\n' "$password" + printf ' BASE_SALT: %s\n\n' "$base_salt" + fi +} + # 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" + + if [[ "$use_versioned_config" == "0" ]] && [[ "$kdf" != "none" ]]; then + # The user is forcing the versioned config off, give them an invocation + # that respects that + printf " transcrypt -c '%s' -p '%s' -md '%s' -k '%s' -bs '%s' -vc '%s'\n" \ + "$cipher" "$escaped_password" "$digest" "$kdf" "$base_salt" "$use_versioned_config" + else + printf " transcrypt -c '%s' -p '%s' -md '%s' -k '%s' -bs '%s'\n" \ + "$cipher" "$escaped_password" "$digest" "$kdf" "$base_salt" + fi } # remove transcrypt-related settings from the repository's git config @@ -709,7 +1097,7 @@ uninstall_transcrypt() { sed -i '/filter=crypt diff=crypt merge=crypt[ \t]*$/d' "$GIT_ATTRIBUTES" ;; esac - + # TODO: do we remove .transcrypt/config here? ie rm -f VERSIONED_TC_CONFIG? if [[ ! $upgrade ]]; then printf 'The transcrypt configuration has been completely removed from the repository.\n' fi @@ -819,14 +1207,14 @@ 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 + 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\ndigest=%s\nkdf=%s\nbase_salt=%s\nuse_versioned_config=%s\n\n' \ + "$password" "$cipher" "$digest" "$kdf" "$base_salt" "$use_versioned_config" | + $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" } @@ -859,6 +1247,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-) + kdf=$(printf '%s' "$configuration" | grep '^kdf' | cut -d'=' -f 2-) + base_salt=$(printf '%s' "$configuration" | grep '^base_salt' | cut -d'=' -f 2-) + use_versioned_config=$(printf '%s' "$configuration" | grep '^use_versioned_config' | cut -d'=' -f 2-) } # print this script's usage message to stderr @@ -897,6 +1289,24 @@ 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. It is strongly recommended to use + a stronger hash (e.g. sha256) if possible. + + -k, --kdf=PBKDF2 + the key-derivation-function to use. Can be either 'pbkdf2' + or 'none'. Defaults to none. It is strongly recommended to + use a kdf (e.g. pbkdf2) if possible. + + -bs, --base-salt=BASE_SALT + if specified, and a KDF is used, this overrides the text + used as the basis for salt-generation. + + -vc, --versioned-config=<0|1> + force transcrypt to use or not use the versioned config. + By default the versioned config is only when using a KDF. + --set-openssl-path=PATH_TO_OPENSSL use OpenSSL at this path; defaults to 'openssl' in \$PATH @@ -987,221 +1397,272 @@ help() { } ##### MAIN - -# reset all variables that might be set -cipher='' -display_config='' -flush_creds='' -gpg_import_file='' -gpg_recipient='' -interactive='true' -list='' -password='' -rekey='' -show_file='' -uninstall='' -upgrade='' -openssl_path='openssl' - -# used to bypass certain safety checks -requires_existing_config='' -requires_clean_repo='true' -ignore_config_status='' # Set for operations where config can exist or not - -# parse command line options -while [[ "${1:-}" != '' ]]; do - case $1 in - clean) - shift - git_clean "$@" - exit $? - ;; - smudge) - shift - git_smudge "$@" - exit $? - ;; - textconv) - shift - git_textconv "$@" - exit $? - ;; - merge) - shift - git_merge "$@" - exit $? - ;; - pre_commit) - shift - git_pre_commit "$@" - exit $? - ;; - -c | --cipher) - cipher=$2 - shift - ;; - --cipher=*) - cipher=${1#*=} - ;; - -p | --password) - password=$2 +transcrypt_main() { + # reset all variables that might be set + cipher='' + display_config='' + flush_creds='' + gpg_import_file='' + gpg_recipient='' + interactive='true' + list='' + password='' + rekey='' + show_file='' + uninstall='' + upgrade='' + openssl_path='openssl' + kdf='' + digest='' + base_salt='' + use_versioned_config='' + + # used to bypass certain safety checks + requires_existing_config='' + requires_clean_repo='true' + ignore_config_status='' # Set for operations where config can exist or not + + # parse command line options + while [[ "${1:-}" != '' ]]; do + case $1 in + clean) + shift + git_clean "$@" + exit $? + ;; + smudge) + shift + git_smudge "$@" + exit $? + ;; + textconv) + shift + git_textconv "$@" + exit $? + ;; + merge) + shift + git_merge "$@" + exit $? + ;; + pre_commit) + shift + git_pre_commit "$@" + exit $? + ;; + -c | --cipher) + cipher=$2 + shift + ;; + --cipher=*) + cipher=${1#*=} + ;; + -md | --digest) + digest=$2 + shift + ;; + --digest=*) + digest=${1#*=} + ;; + -k | --kdf) + kdf=${2} + shift + ;; + --kdf=*) + kdf=${1#*=} + ;; + -bs | --base-salt) + base_salt=$2 + shift + ;; + --base-salt=*) + base_salt=${1#*=} + ;; + -p | --password) + password=$2 + shift + ;; + --password=*) + password=${1#*=} + ;; + -vc | --versioned-config) + use_versioned_config=$2 + shift + ;; + --versioned-config=*) + use_versioned_config=${1#*=} + ;; + --set-openssl-path=*) + openssl_path=${1#*=} + # Immediately apply config setting + git config transcrypt.openssl-path "$openssl_path" + ;; + -y | --yes) + interactive='' + ;; + -d | --display) + display_config='true' + requires_existing_config='true' + requires_clean_repo='' + ;; + -r | --rekey) + rekey='true' + requires_existing_config='true' + ;; + -f | --flush-credentials) + flush_creds='true' + requires_existing_config='true' + ;; + -F | --force) + requires_clean_repo='' + ;; + -u | --uninstall) + uninstall='true' + requires_existing_config='true' + requires_clean_repo='' + ;; + --upgrade) + upgrade='true' + requires_existing_config='true' + requires_clean_repo='' + ;; + -l | --list) + list='true' + requires_clean_repo='' + ignore_config_status='true' + ;; + -s | --show-raw) + show_file=$2 + show_raw_file + exit 0 + ;; + --show-raw=*) + show_file=${1#*=} + show_raw_file + exit 0 + ;; + -e | --export-gpg) + gpg_recipient=$2 + requires_existing_config='true' + requires_clean_repo='' + shift + ;; + --export-gpg=*) + gpg_recipient=${1#*=} + requires_existing_config='true' + requires_clean_repo='' + ;; + -i | --import-gpg) + gpg_import_file=$2 + shift + ;; + --import-gpg=*) + gpg_import_file=${1#*=} + ;; + -v | --version) + printf 'transcrypt %s\n' "$VERSION" + exit 0 + ;; + -h | --help | -\?) + help + exit 0 + ;; + --*) + warn 'unknown option -- %s' "${1#--}" + usage + exit 1 + ;; + *) + warn 'unknown option -- %s' "${1#-}" + usage + exit 1 + ;; + esac shift - ;; - --password=*) - password=${1#*=} - ;; - --set-openssl-path=*) - openssl_path=${1#*=} - # Immediately apply config setting - git config transcrypt.openssl-path "$openssl_path" - ;; - -y | --yes) - interactive='' - ;; - -d | --display) - display_config='true' - requires_existing_config='true' - requires_clean_repo='' - ;; - -r | --rekey) - rekey='true' - requires_existing_config='true' - ;; - -f | --flush-credentials) - flush_creds='true' - requires_existing_config='true' - ;; - -F | --force) - requires_clean_repo='' - ;; - -u | --uninstall) - uninstall='true' - requires_existing_config='true' - requires_clean_repo='' - ;; - --upgrade) - upgrade='true' - requires_existing_config='true' - requires_clean_repo='' - ;; - -l | --list) - list='true' - requires_clean_repo='' - ignore_config_status='true' - ;; - -s | --show-raw) - show_file=$2 - show_raw_file + done + + gather_repo_metadata + + # always run our safety checks + run_safety_checks + + # determine if we are using the versioned config or not + get_use_versioned_config + + # regular expression used to test user input + readonly YES_REGEX='^[Yy]$' + + # in order to keep behavior consistent no matter what order the options were + # specified in, we must run these here rather than in the case statement above + if [[ $list ]]; then + list_files exit 0 - ;; - --show-raw=*) - show_file=${1#*=} - show_raw_file + elif [[ $uninstall ]]; then + uninstall_transcrypt exit 0 - ;; - -e | --export-gpg) - gpg_recipient=$2 - requires_existing_config='true' - requires_clean_repo='' - shift - ;; - --export-gpg=*) - gpg_recipient=${1#*=} - requires_existing_config='true' - requires_clean_repo='' - ;; - -i | --import-gpg) - gpg_import_file=$2 - shift - ;; - --import-gpg=*) - gpg_import_file=${1#*=} - ;; - -v | --version) - printf 'transcrypt %s\n' "$VERSION" + elif [[ $upgrade ]]; then + upgrade_transcrypt exit 0 - ;; - -h | --help | -\?) - help + elif [[ $display_config ]] && [[ $flush_creds ]]; then + display_configuration + printf '\n' + flush_credentials exit 0 - ;; - --*) - warn 'unknown option -- %s' "${1#--}" - usage - exit 1 - ;; - *) - warn 'unknown option -- %s' "${1#-}" - usage - exit 1 - ;; - esac - shift -done - -gather_repo_metadata - -# always run our safety checks -run_safety_checks + elif [[ $display_config ]]; then + display_configuration + exit 0 + elif [[ $flush_creds ]]; then + flush_credentials + exit 0 + elif [[ $gpg_recipient ]]; then + export_gpg + exit 0 + elif [[ $gpg_import_file ]]; then + import_gpg + elif [[ $cipher ]]; then + validate_cipher + fi -# regular expression used to test user input -readonly YES_REGEX='^[Yy]$' + # perform function calls to configure transcrypt + get_cipher + get_digest + get_kdf + get_base_salt + get_password + + if [[ $rekey ]] && [[ $interactive ]]; then + confirm_rekey + elif [[ $interactive ]]; then + confirm_configuration + fi -# in order to keep behavior consistent no matter what order the options were -# specified in, we must run these here rather than in the case statement above -if [[ $list ]]; then - list_files - exit 0 -elif [[ $uninstall ]]; then - uninstall_transcrypt - exit 0 -elif [[ $upgrade ]]; then - upgrade_transcrypt - exit 0 -elif [[ $display_config ]] && [[ $flush_creds ]]; then - display_configuration - printf '\n' - flush_credentials - exit 0 -elif [[ $display_config ]]; then - display_configuration - exit 0 -elif [[ $flush_creds ]]; then - flush_credentials - exit 0 -elif [[ $gpg_recipient ]]; then - export_gpg - exit 0 -elif [[ $gpg_import_file ]]; then - import_gpg -elif [[ $cipher ]]; then - validate_cipher -fi + save_configuration -# perform function calls to configure transcrypt -get_cipher -get_password + if [[ $rekey ]]; then + stage_rekeyed_files + else + force_checkout + fi -if [[ $rekey ]] && [[ $interactive ]]; then - confirm_rekey -elif [[ $interactive ]]; then - confirm_configuration -fi + # ensure the git attributes file exists + if [[ ! -f $GIT_ATTRIBUTES ]]; then + mkdir -p "${GIT_ATTRIBUTES%/*}" + printf '#pattern filter=crypt diff=crypt merge=crypt\n' >"$GIT_ATTRIBUTES" + fi -save_configuration + printf 'The repository has been successfully configured by transcrypt.\n' + #exit 0 +} -if [[ $rekey ]]; then - stage_rekeyed_files +# bpkg convention +# https://github.com/bpkg/bpkg +if [[ ${BASH_SOURCE[0]} != "$0" ]]; then + # We are sourcing the library + #export -f transcrypt_main + echo "Sourcing the library" + #export -p else - force_checkout + # Executing file as a script + transcrypt_main "${@}" + exit $? fi - -# ensure the git attributes file exists -if [[ ! -f $GIT_ATTRIBUTES ]]; then - mkdir -p "${GIT_ATTRIBUTES%/*}" - printf '#pattern filter=crypt diff=crypt merge=crypt\n' >"$GIT_ATTRIBUTES" -fi - -printf 'The repository has been successfully configured by transcrypt.\n' - -exit 0