#!/usr/bin/env nix-shell #! nix-shell -i bash --pure #! nix-shell -p bash openssl git unixtools.column perl set -euo pipefail # # transcrypt - https://github.com/elasticdog/transcrypt # # A script to configure transparent encryption of sensitive files stored in # a Git repository. It utilizes OpenSSL's symmetric cipher routines and follows # the gitattributes(5) man page regarding the use of filters. # # Copyright (c) 2014-2019 Aaron Bull Schaefer # This source code is provided under the terms of the MIT License # that can be be found in the LICENSE file. # ##### CONSTANTS # the release version of this script readonly VERSION='2.2.0' # the default cipher to utilize readonly DEFAULT_CIPHER='aes-256-ctr' # arguments of the openssl enc command readonly ENCRYPT_OPTIONS='-pbkdf2 -iter 200000 -pass env:ENC_PASS' ##### FUNCTIONS # print a canonicalized absolute pathname realpath() { local path=$1 # make path absolute local abspath=$path if [[ -n ${abspath##/*} ]]; then abspath=$(pwd -P)/$abspath fi # canonicalize path local dirname= if [[ -d $abspath ]]; then dirname=$(cd "$abspath" && pwd -P) abspath=$dirname elif [[ -e $abspath ]]; then dirname=$(cd "${abspath%/*}/" 2>/dev/null && pwd -P) abspath=$dirname/${abspath##*/} fi if [[ -d $dirname && -e $abspath ]]; then printf '%s\n' "$abspath" else printf 'invalid path: %s\n' "$path" >&2 exit 1 fi } # establish repository metadata and directory handling # shellcheck disable=SC2155 gather_repo_metadata() { # whether or not transcrypt is already configured readonly CONFIGURED=$(git config --get --local transcrypt.version 2>/dev/null) # the current git repository's top-level directory readonly REPO=$(git rev-parse --show-toplevel 2>/dev/null) # whether or not a HEAD revision exists readonly HEAD_EXISTS=$(git rev-parse --verify --quiet HEAD 2>/dev/null) # https://github.com/RichiH/vcsh # whether or not the git repository is running under vcsh readonly IS_VCSH=$(git config --get --local --bool vcsh.vcsh 2>/dev/null) # whether or not the git repository is bare readonly IS_BARE=$(git rev-parse --is-bare-repository 2>/dev/null || printf 'false') # the current git repository's .git directory readonly RELATIVE_GIT_DIR=$(git rev-parse --git-dir 2>/dev/null || printf '') readonly GIT_DIR=$(realpath "$RELATIVE_GIT_DIR" 2>/dev/null) # Respect transcrypt.crypt-dir if present. Default to crypt/ in Git dir readonly CRYPT_DIR=$(git config transcrypt.crypt-dir 2>/dev/null || printf '%s/crypt' "${RELATIVE_GIT_DIR}") # respect core.hooksPath setting, without trailing slash. Fall back to default hooks dir readonly GIT_HOOKS=$(git config core.hooksPath | sed 's:/*$::' 2>/dev/null || printf "%s/hooks" "${RELATIVE_GIT_DIR}") # the current git repository's gitattributes file local CORE_ATTRIBUTES CORE_ATTRIBUTES=$(git config --get --local --path core.attributesFile 2>/dev/null || git config --get --path core.attributesFile 2>/dev/null || printf '') if [[ $CORE_ATTRIBUTES ]]; then readonly GIT_ATTRIBUTES=$CORE_ATTRIBUTES elif [[ $IS_BARE == 'true' ]] || [[ $IS_VCSH == 'true' ]]; then readonly GIT_ATTRIBUTES="${GIT_DIR}/info/attributes" else readonly GIT_ATTRIBUTES="${REPO}/.gitattributes" fi } # print a message to stderr warn() { local fmt="$1" shift # shellcheck disable=SC2059 printf "transcrypt: $fmt\n" "$@" >&2 } # print a message to stderr and exit with either # the given status or that of the most recent command die() { local st="$?" if [[ "$1" != *[^0-9]* ]]; then st="$1" shift fi warn "$@" exit "$st" } # The `decryption -> encryption` process on an unchanged file must be # 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 # then use the last 16 bytes of that HMAC for the file's unique salt. git_clean() { filename=$1 # ignore empty files if [[ ! -s $filename ]]; then return fi # cache STDIN to test if it's already encrypted 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) firstbytes=$(head -c8 "$tempfile" | LC_ALL=C tr -d '\0') if [[ $firstbytes == "U2FsdGVk" ]]; then 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" | perl -pe 's/(..)/chr(hex($1))/ge' && # Encrypt file to binary ciphertext ENC_PASS="$password" "$openssl_path" enc -e -$cipher $ENCRYPT_OPTIONS -S "$salt" -in "$tempfile" ) | openssl base64 else # Encrypt file to base64 ciphertext ENC_PASS="$password" "$openssl_path" enc -e -a -$cipher $ENCRYPT_OPTIONS -S "$salt" -in "$tempfile" fi fi } git_smudge() { 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 $ENCRYPT_OPTIONS -a 2>/dev/null || cat "$tempfile" } git_textconv() { 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 $ENCRYPT_OPTIONS -a -in "$filename" 2>/dev/null || cat "$filename" } # shellcheck disable=SC2005,SC2002,SC2181 git_merge() { # Get path to transcrypt in this script's directory TRANSCRYPT_PATH="$(dirname "$0")/transcrypt" # Look up name of local branch/ref to which changes are being merged OURS_LABEL=$(git rev-parse --abbrev-ref HEAD) # Look up name of the incoming "theirs" branch/ref being merged in. # TODO There must be a better way of doing this than relying on this reflog # action environment variable, but I don't know what it is if [[ "$GIT_REFLOG_ACTION" = "merge "* ]]; then THEIRS_LABEL=$(echo "$GIT_REFLOG_ACTION" | awk '{print $2}') fi if [[ ! "$THEIRS_LABEL" ]]; then THEIRS_LABEL="theirs" fi # Decrypt BASE $1, LOCAL $2, and REMOTE $3 versions of file being merged echo "$(cat "$1" | "${TRANSCRYPT_PATH}" smudge)" >"$1" echo "$(cat "$2" | "${TRANSCRYPT_PATH}" smudge)" >"$2" echo "$(cat "$3" | "${TRANSCRYPT_PATH}" smudge)" >"$3" # Merge the decrypted files to the temp file named by $2 git merge-file --marker-size="$4" -L "$OURS_LABEL" -L base -L "$THEIRS_LABEL" "$2" "$1" "$3" # If the merge was not successful (has conflicts) exit with an error code to # leave the partially-merged file in place for a manual merge. if [[ "$?" != "0" ]]; then exit 1 fi # If the merge was successful (no conflicts) re-encrypt the merged temp file $2 # which git will then update in the index in a following "Auto-merging" step. # We must explicitly encrypt/clean the file, rather than leave Git to do it, # because we can otherwise trigger safety check failure errors like: # error: add_cacheinfo failed to refresh for path 'FILE'; merge aborting. # To re-encrypt we must first copy the merged file to $5 (the name of the # working-copy file) so the crypt `clean` script can generate the correct hash # salt based on the file's real name, instead of the $2 temp file name. cp "$2" "$5" # Now we use the `clean` script to encrypt the merged file contents back to the # temp file $2 where Git expects to find the merge result content. cat "$5" | "${TRANSCRYPT_PATH}" clean "$5" >"$2" } # shellcheck disable=SC2155 git_pre_commit() { # Transcrypt pre-commit hook: fail if secret file in staging lacks the magic prefix "Salted" in B64 tmp=$(mktemp) IFS=$'\n' slow_mode_if_failed() { for secret_file in $(git -c core.quotePath=false ls-files | git -c core.quotePath=false check-attr --stdin filter | awk 'BEGIN { FS = ":" }; /crypt$/{ print $1 }'); do # Skip symlinks, they contain the linked target file path not plaintext if [[ -L $secret_file ]]; then continue fi # Get prefix of raw file in Git's index using the :FILENAME revision syntax local firstbytes=$(git show :"${secret_file}" | head -c8) # An empty file does not need to be, and is not, encrypted if [[ $firstbytes == "" ]]; then : # Do nothing # The first bytes of an encrypted file must be "Salted" in Base64 elif [[ $firstbytes != "U2FsdGVk" ]]; then 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 printf ' .gitattribute filters as required by Transcrypt.\n' >&2 printf '\n' >&2 printf 'Fix this by re-staging the file with a compatible tool or with' printf ' Git on the command line:\n' >&2 printf '\n' >&2 printf ' git rm --cached -- %s\n' "$secret_file" >&2 printf ' git add %s\n' "$secret_file" >&2 printf '\n' >&2 exit 1 fi done } # validate file to see if it failed or not, We don't care about the filename currently for speed, we only care about pass/fail, slow_mode_if_failed() is for what failed. validate_file() { secret_file=${1} # Skip symlinks, they contain the linked target file path not plaintext if [[ -L $secret_file ]]; then return fi # Get prefix of raw file in Git's index using the :FILENAME revision syntax # The first bytes of an encrypted file are always "Salted" in Base64 local firstbytes=$(git show :"${secret_file}" | head -c8) if [[ $firstbytes != "U2FsdGVk" ]]; then echo "true" >>"${tmp}" fi } # if bash version is 4.4 or greater than fork to number of threads otherwise run normally if [[ "${BASH_VERSINFO[0]}" -ge 4 ]] && [[ "${BASH_VERSINFO[1]}" -ge 4 ]]; then num_procs=$(nproc) num_jobs="\j" for secret_file in $(git -c core.quotePath=false ls-files | git -c core.quotePath=false check-attr --stdin filter | awk 'BEGIN { FS = ":" }; /crypt$/{ print $1 }'); do while ((${num_jobs@P} >= num_procs)); do wait -n done validate_file "${secret_file}" & done wait if [[ -s ${tmp} ]]; then slow_mode_if_failed rm -f "${tmp}" exit 1 fi else slow_mode_if_failed fi rm -f "${tmp}" unset IFS } # verify that all requirements have been met run_safety_checks() { # validate that we're in a git repository [[ $GIT_DIR ]] || die 'you are not currently in a git repository; did you forget to run "git init"?' # exit if transcrypt is not in the required state if [[ $ignore_config_status ]]; then : # no-op, no need to check $CONFIGURED status elif [[ $requires_existing_config ]] && [[ ! $CONFIGURED ]]; then die 1 'the current repository is not configured' elif [[ ! $requires_existing_config ]] && [[ $CONFIGURED ]]; then die 1 'the current repository is already configured; see --display' fi # check for dependencies for cmd in {column,grep,mktemp,"${openssl_path}",sed,tee}; do command -v "$cmd" >/dev/null || die 'required command "%s" was not found' "$cmd" done # ensure the repository is clean (if it has a HEAD revision) so we can force # checkout files without the destruction of uncommitted changes if [[ $requires_clean_repo ]] && [[ $HEAD_EXISTS ]] && [[ $IS_BARE == 'false' ]]; then # ensure index is up-to-date before dirty check git update-index -q --really-refresh # check if the repo is dirty if ! git diff-index --quiet HEAD --; then die 1 'the repo is dirty; commit or stash your changes before running transcrypt' fi fi } # unset the cipher variable if it is not supported by openssl validate_cipher() { local list_cipher_commands list_cipher_commands="${openssl_path} enc -ciphers" local supported supported=$($list_cipher_commands | tr -s ' ' '\n' | grep -Fx -- "-$cipher") || true if [[ ! $supported ]]; then if [[ $interactive ]]; then printf '"%s" is not a valid cipher; choose one of the following:\n\n' "$cipher" $list_cipher_commands | column -c 80 printf '\n' cipher='' else # shellcheck disable=SC2016 die 1 '"%s" is not a valid cipher; see `%s`' "$cipher" "$list_cipher_commands" fi fi } # 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 # use the default cipher if the user gave no answer; # otherwise verify the given cipher is supported by openssl if [[ ! $answer ]]; then cipher=$DEFAULT_CIPHER else cipher=$answer validate_cipher fi done } # ensure we have a password to encrypt with get_password() { while [[ ! $password ]]; do local answer= if [[ $interactive ]]; then printf 'Generate a random password? [Y/n] ' read -r -n 1 -s answer printf '\n' fi # generate a random password if the user answered yes; # otherwise prompt the user for a password if [[ $answer =~ $YES_REGEX ]] || [[ ! $answer ]]; then local password_length=30 local random_base64 random_base64=$(${openssl_path} rand -base64 $password_length) password=$random_base64 else printf 'Password: ' read -r password [[ $password ]] || printf 'no password was specified\n' fi done } # confirm the transcrypt configuration 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" printf 'The following configuration will be saved:\n\n' printf ' CIPHER: %s\n' "$cipher" printf ' PASSWORD: %s\n\n' "$password" printf 'Does this look correct? [Y/n] ' read -r -n 1 -s answer # exit if the user did not confirm if [[ $answer =~ $YES_REGEX ]] || [[ ! $answer ]]; then printf '\n\n' else printf '\n' die 1 'configuration has been aborted' fi } # confirm the rekey 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" printf 'The following configuration will be saved:\n\n' printf ' CIPHER: %s\n' "$cipher" printf ' PASSWORD: %s\n\n' "$password" 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] ' read -r answer # only rekey if the user explicitly confirmed if [[ $answer =~ $YES_REGEX ]]; then printf '\n' else die 1 'rekeying has been aborted' fi } # automatically stage rekeyed files in preparation for the user to commit them stage_rekeyed_files() { local encrypted_files encrypted_files=$(git ls-crypt) if [[ $encrypted_files ]] && [[ $IS_BARE == 'false' ]]; then # touch all encrypted files to prevent stale stat info cd "$REPO" || die 1 'could not change into the "%s" directory' "$REPO" # shellcheck disable=SC2086 touch $encrypted_files # shellcheck disable=SC2086 git update-index --add -- $encrypted_files printf '*** rekeyed files have been staged ***\n' printf '*** COMMIT THESE CHANGES RIGHT AWAY! ***\n\n' fi } # save helper scripts under the repository's git directory save_helper_scripts() { mkdir -p "${CRYPT_DIR}" local current_transcrypt current_transcrypt=$(realpath "$0" 2>/dev/null) echo '#!/usr/bin/env bash' > "${CRYPT_DIR}/transcrypt" tail -n +4 "$current_transcrypt" >> "${CRYPT_DIR}/transcrypt" # make scripts executable for script in {transcrypt,}; do chmod 0755 "${CRYPT_DIR}/${script}" done } # save helper hooks under the repository's git directory save_helper_hooks() { # Install pre-commit-crypt hook script [[ ! -d "${GIT_HOOKS}" ]] && mkdir -p "${GIT_HOOKS}" pre_commit_hook_installed="${GIT_HOOKS}/pre-commit-crypt" cat <<-'EOF' >"$pre_commit_hook_installed" #!/usr/bin/env bash # Transcrypt pre-commit hook: fail if secret file in staging lacks the magic prefix "Salted" in B64 RELATIVE_GIT_DIR=$(git rev-parse --git-dir 2>/dev/null || printf '') CRYPT_DIR=$(git config transcrypt.crypt-dir 2>/dev/null || printf '%s/crypt' "${RELATIVE_GIT_DIR}") "${CRYPT_DIR}/transcrypt" pre_commit EOF # Activate hook by copying it to the pre-commit script name, if safe to do so pre_commit_hook="${GIT_HOOKS}/pre-commit" if [[ -f "$pre_commit_hook" ]]; then printf 'WARNING:\n' >&2 printf 'Cannot install Git pre-commit hook script because file already exists: %s\n' "$pre_commit_hook" >&2 printf 'Please manually install the pre-commit script saved as: %s\n' "$pre_commit_hook_installed" >&2 printf '\n' else cp "$pre_commit_hook_installed" "$pre_commit_hook" chmod 0755 "$pre_commit_hook" fi } # write 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" # write the filter settings. Sorry for the horrific quote escaping below... # shellcheck disable=SC2016 git config filter.crypt.clean '"$(git config transcrypt.crypt-dir 2>/dev/null || printf ''%s/crypt'' ""$(git rev-parse --git-dir)"")"/transcrypt clean %f' # shellcheck disable=SC2016 git config filter.crypt.smudge '"$(git config transcrypt.crypt-dir 2>/dev/null || printf ''%s/crypt'' ""$(git rev-parse --git-dir)"")"/transcrypt smudge' # shellcheck disable=SC2016 git config diff.crypt.textconv '"$(git config transcrypt.crypt-dir 2>/dev/null || printf ''%s/crypt'' ""$(git rev-parse --git-dir)"")"/transcrypt textconv' # shellcheck disable=SC2016 git config merge.crypt.driver '"$(git config transcrypt.crypt-dir 2>/dev/null || printf ''%s/crypt'' ""$(git rev-parse --git-dir)"")"/transcrypt merge %O %A %B %L %P' git config filter.crypt.required 'true' git config diff.crypt.cachetextconv 'true' git config diff.crypt.binary 'true' git config merge.renormalize 'true' git config merge.crypt.name 'Merge transcrypt secret files' # add a git alias for listing encrypted files 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 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//\'/\'\\\'\'} 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" 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" } # remove transcrypt-related settings from the repository's git config clean_gitconfig() { git config --remove-section transcrypt 2>/dev/null || true git config --remove-section filter.crypt 2>/dev/null || true git config --remove-section diff.crypt 2>/dev/null || true git config --remove-section merge.crypt 2>/dev/null || true git config --unset merge.renormalize # remove the merge section if it's now empty local merge_values merge_values=$(git config --get-regex --local 'merge\..*') || true if [[ ! $merge_values ]]; then git config --remove-section merge 2>/dev/null || true fi } # Remove from the local Git DB any objects containing the cached plaintext of # secret files, created due to the setting diff.crypt.cachetextconv='true' remove_cached_plaintext() { # Delete ref to cached plaintext objects, to leave these objects # unreferenced and available for removal git update-ref -d refs/notes/textconv/crypt # Remove ANY unreferenced objects in Git's object DB (packed or unpacked), # to ensure that cached plaintext objects are also removed. # The vital sub-commands equivalents we require this `gc` command to do are: # `git prune`, `git repack -ad` git gc --prune=now --quiet } # force the checkout of any files with the crypt filter applied to them; # this will decrypt existing encrypted files if you've just cloned a repository, # or it will encrypt locally decrypted files if you've just flushed the credentials force_checkout() { # make sure a HEAD revision exists if [[ $HEAD_EXISTS ]] && [[ $IS_BARE == 'false' ]]; then # this would normally delete uncommitted changes in the working directory, # but we already made sure the repo was clean during the safety checks local encrypted_files encrypted_files=$(git ls-crypt) cd "$REPO" || die 1 'could not change into the "%s" directory' "$REPO" IFS=$'\n' for file in $encrypted_files; do rm -f "$file" git checkout --force HEAD -- "$file" >/dev/null done unset IFS fi } # remove the locally cached encryption credentials and # re-encrypt any files that had been previously decrypted flush_credentials() { local answer= if [[ $interactive ]]; then printf 'You are about to flush the local credentials; make sure you have saved them elsewhere.\n' printf 'All previously decrypted files will revert to their encrypted form, and your\n' printf 'repo will be garbage collected to remove any cached plaintext of secret files.\n\n' printf 'Proceed with credential flush? [y/N] ' read -r answer printf '\n' else # although destructive, we should support the --yes option answer='y' fi # only flush if the user explicitly confirmed if [[ $answer =~ $YES_REGEX ]]; then clean_gitconfig remove_cached_plaintext # re-encrypt any files that had been previously decrypted force_checkout printf 'The local transcrypt credentials have been successfully flushed.\n' else die 1 'flushing of credentials has been aborted' fi } # remove all transcrypt configuration from the repository uninstall_transcrypt() { local answer= if [[ $interactive ]]; then printf 'You are about to remove all transcrypt configuration from your repository.\n' printf 'All previously encrypted files will remain decrypted in this working copy, but your\n' printf 'repo will be garbage collected to remove any cached plaintext of secret files.\n\n' printf 'Proceed with uninstall? [y/N] ' read -r answer printf '\n' else # although destructive, we should support the --yes option answer='y' fi # only uninstall if the user explicitly confirmed if [[ $answer =~ $YES_REGEX ]]; then clean_gitconfig if [[ ! $upgrade ]]; then remove_cached_plaintext fi # remove helper scripts # Keep obsolete clean,smudge,textconv,merge refs here to remove them on upgrade for script in {transcrypt,clean,smudge,textconv,merge}; do [[ ! -f "${CRYPT_DIR}/${script}" ]] || rm "${CRYPT_DIR}/${script}" done [[ ! -d "${CRYPT_DIR}" ]] || rmdir "${CRYPT_DIR}" # rename helper hooks (don't delete, in case user has custom changes) pre_commit_hook="${GIT_HOOKS}/pre-commit" pre_commit_hook_installed="${GIT_HOOKS}/pre-commit-crypt" if [[ -f "$pre_commit_hook" ]]; then hook_md5=$("${openssl_path}" md5 -hex <"$pre_commit_hook") installed_md5=$("${openssl_path}" md5 -hex <"$pre_commit_hook_installed") if [[ "$hook_md5" = "$installed_md5" ]]; then rm "$pre_commit_hook" else printf 'WARNING: Cannot safely disable Git pre-commit hook %s please check it yourself\n' "$pre_commit_hook" fi fi [[ -f "$pre_commit_hook_installed" ]] && rm "$pre_commit_hook_installed" # touch all encrypted files to prevent stale stat info local encrypted_files encrypted_files=$(git ls-crypt) if [[ $encrypted_files ]] && [[ $IS_BARE == 'false' ]]; then cd "$REPO" || die 1 'could not change into the "%s" directory' "$REPO" # shellcheck disable=SC2086 touch $encrypted_files fi # remove the `git ls-crypt` alias git config --unset alias.ls-crypt # remove the alias section if it's now empty local alias_values alias_values=$(git config --get-regex --local 'alias\..*') || true if [[ ! $alias_values ]]; then git config --remove-section alias 2>/dev/null || true fi # remove any defined crypt patterns in gitattributes case $OSTYPE in darwin*) /usr/bin/sed -i '' '/filter=crypt diff=crypt[ \t]*$/d' "$GIT_ATTRIBUTES" /usr/bin/sed -i '' '/filter=crypt diff=crypt merge=crypt[ \t]*$/d' "$GIT_ATTRIBUTES" ;; linux*) sed -i '/filter=crypt diff=crypt[ \t]*$/d' "$GIT_ATTRIBUTES" sed -i '/filter=crypt diff=crypt merge=crypt[ \t]*$/d' "$GIT_ATTRIBUTES" ;; esac if [[ ! $upgrade ]]; then printf 'The transcrypt configuration has been completely removed from the repository.\n' fi else die 1 'uninstallation has been aborted' fi } # uninstall and re-install transcrypt to upgrade scripts and update configuration upgrade_transcrypt() { CURRENT_VERSION=$(git config --get --local transcrypt.version 2>/dev/null) if [[ $interactive ]]; then printf 'You are about to upgrade the transcrypt scripts in your repository.\n' printf 'Your configuration settings will not be changed.\n\n' printf ' Current version: %s\n' "$CURRENT_VERSION" printf 'Upgraded version: %s\n\n' "$VERSION" printf 'Proceed with upgrade? [y/N] ' read -r answer printf '\n' if [[ $answer =~ $YES_REGEX ]]; then # User confirmed, don't prompt again interactive='' else # User did not confirm, exit # Exit if user did not confirm die 1 'upgrade has been aborted' fi fi # Keep current cipher and password cipher=$(git config --get --local transcrypt.cipher) password=$(git config --get --local transcrypt.password) # Keep current openssl-path, or set to default if no existing value openssl_path=$(git config --get --local transcrypt.openssl-path 2>/dev/null || printf '%s' "$openssl_path") # Keep contents of .gitattributes ORIG_GITATTRIBUTES=$(cat "$GIT_ATTRIBUTES") uninstall_transcrypt save_configuration # Re-instate contents of .gitattributes echo "$ORIG_GITATTRIBUTES" >"$GIT_ATTRIBUTES" # Update .gitattributes for transcrypt'ed files to include "merge=crypt" config case $OSTYPE in darwin*) /usr/bin/sed -i '' 's/=crypt\(.*\)/=crypt diff=crypt merge=crypt/' "$GIT_ATTRIBUTES" ;; linux*) sed -i 's/=crypt\(.*\)/=crypt diff=crypt merge=crypt/' "$GIT_ATTRIBUTES" ;; esac printf 'Upgrade is complete\n' LATEST_GITATTRIBUTES=$(cat "$GIT_ATTRIBUTES") if [[ "$LATEST_GITATTRIBUTES" != "$ORIG_GITATTRIBUTES" ]]; then printf '\nYour gitattributes file has been updated with the latest recommended values.\n' printf 'Please review and commit the new values in:\n' printf '%s\n' "$GIT_ATTRIBUTES" fi } # list all of the currently encrypted files in the repository list_files() { if [[ $IS_BARE == 'false' ]]; then cd "$REPO" || die 1 'could not change into the "%s" directory' "$REPO" git -c core.quotePath=false ls-files | git -c core.quotePath=false check-attr --stdin filter | awk 'BEGIN { FS = ":" }; /crypt$/{ print $1 }' fi } # show the raw file as stored in the git commit object show_raw_file() { if [[ -f $show_file ]]; then # ensure the file is currently being tracked local escaped_file=${show_file//\//\\\/} if git -c core.quotePath=false ls-files --others -- "$show_file" | awk "/${escaped_file}/{ exit 1 }"; then file_paths=$(git -c core.quotePath=false ls-tree --name-only --full-name HEAD "$show_file") else die 1 'the file "%s" is not currently being tracked by git' "$show_file" fi elif [[ $show_file == '*' ]]; then file_paths=$(git ls-crypt) else die 1 'the file "%s" does not exist' "$show_file" fi IFS=$'\n' for file in $file_paths; do printf '==> %s <==\n' "$file" >&2 git --no-pager show HEAD:"$file" --no-textconv printf '\n' >&2 done unset IFS } # export password and cipher to a gpg encrypted file export_gpg() { # check for dependencies command -v gpg >/dev/null || die 'required command "gpg" was not found' # ensure the recipient key exists if ! gpg --list-keys "$gpg_recipient" 2>/dev/null; then 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) 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 "The transcrypt configuration has been encrypted and exported to:\n%s/crypt/%s.asc\n" "$GIT_DIR" "$gpg_recipient" } # import password and cipher from a gpg encrypted file import_gpg() { # check for dependencies command -v gpg >/dev/null || die 'required command "gpg" was not found' local path if [[ -f "${CRYPT_DIR}/${gpg_import_file}" ]]; then path="${CRYPT_DIR}/${gpg_import_file}" elif [[ -f "${CRYPT_DIR}/${gpg_import_file}.asc" ]]; then path="${CRYPT_DIR}/${gpg_import_file}.asc" elif [[ ! -f $gpg_import_file ]]; then die 1 'the file "%s" does not exist' "$gpg_import_file" else path="$gpg_import_file" fi local configuration='' local safety_counter=0 # fix for intermittent 'no secret key' decryption failures while [[ ! $configuration ]]; do configuration=$(gpg --batch --quiet --decrypt "$path") safety_counter=$((safety_counter + 1)) if [[ $safety_counter -eq 3 ]]; then die 1 'unable to decrypt the file "%s"' "$path" fi done cipher=$(printf '%s' "$configuration" | grep '^cipher' | cut -d'=' -f 2-) password=$(printf '%s' "$configuration" | grep '^password' | cut -d'=' -f 2-) } # print this script's usage message to stderr usage() { cat <<-EOF >&2 usage: transcrypt [-c CIPHER] [-p PASSWORD] [-h] EOF } # print this script's help message to stdout help() { cat <<-EOF NAME transcrypt -- transparently encrypt files within a git repository SYNOPSIS transcrypt [options...] 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. The transcrypt source code and full documentation may be downloaded from https://github.com/elasticdog/transcrypt. OPTIONS -c, --cipher=CIPHER the symmetric cipher to utilize for encryption; defaults to aes-256-cbc -p, --password=PASSWORD the password to derive the key from; defaults to 30 random base64 characters --set-openssl-path=PATH_TO_OPENSSL use OpenSSL at this path; defaults to 'openssl' in \$PATH -y, --yes assume yes and accept defaults for non-specified options -d, --display display the current repository's cipher and password -r, --rekey re-encrypt all encrypted files using new credentials -f, --flush-credentials remove the locally cached encryption credentials and re-encrypt any files that had been previously decrypted -F, --force ignore whether the git directory is clean, proceed with the possibility that uncommitted changes are overwritten -u, --uninstall remove all transcrypt configuration from the repository and leave files in the current working copy decrypted --upgrade apply the latest transcrypt scripts in the repository without changing your configuration settings -l, --list list all of the transparently encrypted files in the repository, relative to the top-level directory -s, --show-raw=FILE show the raw file as stored in the git commit object; use this to check if files are encrypted as expected -e, --export-gpg=RECIPIENT export the repository's cipher and password to a file encrypted for a gpg recipient -i, --import-gpg=FILE import the password and cipher from a gpg encrypted file -v, --version print the version information -h, --help view this help message 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 corre- sponding option flags were not given. $ cd / $ transcrypt Once a repository has been configured with transcrypt, you can trans- parently encrypt files by applying the "crypt" filter, diff and merge to a pattern in the top-level .gitattributes config. If that pattern matches a file in your repository, the file will be transparently encrypted once you stage and commit it: $ 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' See the gitattributes(5) man page for more information. 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 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. AUTHOR Aaron Bull Schaefer SEE ALSO enc(1), gitattributes(5) EOF } ##### 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 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 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 done gather_repo_metadata # always run our safety checks run_safety_checks # 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 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 # perform function calls to configure transcrypt get_cipher get_password if [[ $rekey ]] && [[ $interactive ]]; then confirm_rekey elif [[ $interactive ]]; then confirm_configuration fi save_configuration if [[ $rekey ]]; then stage_rekeyed_files else force_checkout 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