diff --git a/LICENSE b/LICENSE
index 5ed94e0..d925757 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,4 +1,5 @@
-Copyright (c) 2014-2019, Aaron Bull Schaefer <aaron@elasticdog.com>
+Copyright (c) 2020-2023, James Murty <james@murty.co>
+Copyright (c) 2014-2020, Aaron Bull Schaefer <aaron@elasticdog.com>
 Copyright (c) 2011, Woody Gilk <woody.gilk@gmail.com>
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
diff --git a/README.md b/README.md
index 2fc8fb0..e04530c 100644
--- a/README.md
+++ b/README.md
@@ -151,16 +151,26 @@ repository. The owner of the origin repository can dump the credentials for you
 by running the `--display` command line option:
 
     $ transcrypt --display
-    The current repository was configured using transcrypt v0.2.0
+
+    The current repository was configured using transcrypt version 3.0.0-alpha1
     and has the following configuration:
 
-      CONTEXT:  default
-      CIPHER:   aes-256-cbc
-      PASSWORD: correct horse battery staple
+      GIT_WORK_TREE:  transcrypt
+      GIT_DIR:        transcrypt/.git
+      GIT_ATTRIBUTES: transcrypt/.gitattributes
+
+      CONTEXT:        default
+      CIPHER:         aes-256-cbc
+      DIGEST:         sha512
+      KDF:            pbkdf2
+      ITERATIONS:     256_000
+      PROJECT SALT:   5J0QY8uOTe7/B9eYSJ2kOy91
+      PASSWORD:       correct horse battery staple
 
     Copy and paste the following command to initialize a cloned repository:
 
-      transcrypt -c aes-256-cbc -p 'correct horse battery staple'
+      transcrypt -c aes-256-cbc -md sha512 -k pbkdf2 -n 256_000 \
+        -ps 5J0QY8uOTe7/B9eYSJ2kOy91 -p 'correct horse battery staple'
 
 Once transcrypt has stored the matching credentials, it will force a checkout of
 any exising encrypted files in order to decrypt them.
@@ -200,6 +210,25 @@ directory.
              the symmetric cipher to utilize for encryption;
              defaults to aes-256-cbc
 
+      -md, --digest=DIGEST
+             the message digest used to hash the salted password;
+             defaults to sha512
+             Use md5 for compatibility with transcrypt versions < 3
+
+      -k, --kdf=KEY_DERIVATION_FUNCTION
+             a key-derivation function to use for strongest encryption;
+             defaults to pbkdf2
+             If enabled, all users will need Transcrypt 3+ and modern OpenSSL
+
+      -n, --iter=ITERATIONS
+             when using a key-derivation function, its number of iterations;
+             defaults to 256_000
+
+      -ps, --salt=PROJECT_SALT
+             when using a key-derivation function, an extra value to
+             strengthen per-file salt values;
+             defaults to 18 random base64 characters
+
       -p, --password=PASSWORD
              the password to derive the key from;
              defaults to 30 random base64 characters
@@ -344,6 +373,7 @@ to encrypt a file \_top-secret* in a "super" context:
 transcrypt is provided under the terms of the
 [MIT License](https://en.wikipedia.org/wiki/MIT_License).
 
+Copyright &copy; 2020-2023, [James Murty](mailto:james@murty.co).
 Copyright &copy; 2014-2020, [Aaron Bull Schaefer](mailto:aaron@elasticdog.com).
 
 ## Contributing
diff --git a/contrib/packaging/pacman/PKGBUILD b/contrib/packaging/pacman/PKGBUILD
index 248a93a..8417706 100644
--- a/contrib/packaging/pacman/PKGBUILD
+++ b/contrib/packaging/pacman/PKGBUILD
@@ -1,6 +1,6 @@
 # Maintainer: Aaron Bull Schaefer <aaron@elasticdog.com>
 pkgname=transcrypt
-pkgver=2.3.0-pre
+pkgver=3.0.0-alpha1
 pkgrel=1
 pkgdesc='A script to configure transparent encryption of files within a Git repository'
 arch=('any')
diff --git a/sensitive_file b/sensitive_file
index 547ad71..8ce6c13 100644
--- a/sensitive_file
+++ b/sensitive_file
@@ -1,40 +1,40 @@
-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
-L/aeTVBw2BnDs2K48meuFw==
+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
+GsaN2D2yI3S3YjuOOIpjHA==
diff --git a/tests/_test_helper.bash b/tests/_test_helper.bash
index ebf2ba3..4aa43ac 100644
--- a/tests/_test_helper.bash
+++ b/tests/_test_helper.bash
@@ -39,10 +39,6 @@ function cleanup_all {
   rm -f "$BATS_TEST_DIRNAME"/sensitive_file
 }
 
-function init_transcrypt {
-  "$BATS_TEST_DIRNAME"/../transcrypt --cipher=aes-256-cbc --password='abc 123' --yes
-}
-
 function encrypt_named_file {
   filename="$1"
   content=$2
@@ -59,10 +55,25 @@ function encrypt_named_file {
   run git commit -m "Encrypt file \"$filename\""
 }
 
+function init_transcrypt_no_kdf {
+  "$BATS_TEST_DIRNAME"/../transcrypt --cipher=aes-256-cbc --password='abc 123' --yes
+}
+
+function init_transcrypt {
+  "$BATS_TEST_DIRNAME"/../transcrypt --cipher=aes-256-cbc --digest sha512 --kdf pbkdf2 --iter 99 --salt 5J0Q --password='abc 123' --yes
+}
+
 function setup {
   pushd "$BATS_TEST_DIRNAME" || exit 1
   init_git_repo
-  if [[ ! "$SETUP_SKIP_INIT_TRANSCRYPT" ]]; then
+
+  if [[ "$SETUP_SKIP_INIT_TRANSCRYPT" ]]; then
+    return
+  fi
+
+  if [[ "$SETUP_INIT_TRANSCRYPT_NO_KDF" ]]; then
+    init_transcrypt_no_kdf
+  else
     init_transcrypt
   fi
 }
diff --git a/tests/test_cleanup.bats b/tests/test_cleanup.bats
index fc00077..7796960 100755
--- a/tests/test_cleanup.bats
+++ b/tests/test_cleanup.bats
@@ -3,7 +3,16 @@
 load "$BATS_TEST_DIRNAME/_test_helper.bash"
 
 SECRET_CONTENT="My secret content"
-SECRET_CONTENT_ENC="U2FsdGVkX1/6ilR0PmJpAyCF7iG3+k4aBwbgVd48WaQXznsg42nXbQrlWsf/qiCg"
+
+# Example generation:
+# - Using project salt: 5J0Q
+# - Generate file key
+#   openssl dgst -hmac "sensitive_file:5J0Q" -sha256 sensitive_file  | tr -d '\r\n' | tail -c16
+#   => ec32c0fbf2261d18
+# - Encrypt file
+#   cat sensitive_file | ENC_PASS='abc 123' openssl enc -e -a -aes-256-cbc -md sha512 -pass env:ENC_PASS -pbkdf2 -iter 99 -S ec32c0fbf2261d18
+#   => U2FsdGVkX1+NiURgsIjgkwyiBw0TSC8WhhDRly2h4x2exuwjay6y/nOahblrBL62
+SECRET_CONTENT_ENC="U2FsdGVkX1+NiURgsIjgkwyiBw0TSC8WhhDRly2h4x2exuwjay6y/nOahblrBL62"
 
 @test "cleanup: transcrypt -f flush clears cached plaintext" {
   encrypt_named_file sensitive_file "$SECRET_CONTENT"
diff --git a/tests/test_contexts.bats b/tests/test_contexts_no_kdf.bats
similarity index 98%
rename from tests/test_contexts.bats
rename to tests/test_contexts_no_kdf.bats
index f0f9de7..bd4b7be 100755
--- a/tests/test_contexts.bats
+++ b/tests/test_contexts_no_kdf.bats
@@ -9,7 +9,7 @@ SUPER_SECRET_CONTENT_ENC="U2FsdGVkX1+dAkIV/LAKXMmqjDNOGoOVK8Rmhw9tUnbR4dwBDglpkX
 function setup {
   pushd "$BATS_TEST_DIRNAME" || exit 1
   init_git_repo
-  init_transcrypt
+  init_transcrypt_no_kdf
 
   # Init transcrypt with 'super-secret' context
   "$BATS_TEST_DIRNAME"/../transcrypt --context=super-secret --cipher=aes-256-cbc --password=321cba --yes
@@ -86,9 +86,9 @@ function teardown {
   [ "$status" -eq 0 ]
   [ "${lines[0]}" = "The current repository was configured using transcrypt version $VERSION" ]
   [ "${lines[1]}" = "and has the following configuration for context 'super-secret':" ]
-  [ "${lines[5]}" = "  CONTEXT:  super-secret" ]
-  [ "${lines[6]}" = "  CIPHER:   aes-256-cbc" ]
-  [ "${lines[7]}" = "  PASSWORD: 321cba" ]
+  [ "${lines[5]}" = "  CONTEXT:        super-secret" ]
+  [ "${lines[6]}" = "  CIPHER:         aes-256-cbc" ]
+  [ "${lines[7]}" = "  PASSWORD:       321cba" ]
   [ "${lines[8]}" = "The repository has 2 contexts: default super-secret" ]
   [ "${lines[9]}" = "Copy and paste the following command to initialize a cloned repository for context 'super-secret':" ]
   [ "${lines[10]}" = "  transcrypt -C super-secret -c aes-256-cbc -p '321cba'" ]
diff --git a/tests/test_crypt.bats b/tests/test_crypt_no_kdf.bats
similarity index 99%
rename from tests/test_crypt.bats
rename to tests/test_crypt_no_kdf.bats
index 1186843..24feac6 100755
--- a/tests/test_crypt.bats
+++ b/tests/test_crypt_no_kdf.bats
@@ -2,6 +2,9 @@
 
 load "$BATS_TEST_DIRNAME/_test_helper.bash"
 
+# Custom setup: use no-KDF init transcrypt
+SETUP_INIT_TRANSCRYPT_NO_KDF=1
+
 SECRET_CONTENT="My secret content"
 SECRET_CONTENT_ENC="U2FsdGVkX1/6ilR0PmJpAyCF7iG3+k4aBwbgVd48WaQXznsg42nXbQrlWsf/qiCg"
 
diff --git a/tests/test_init.bats b/tests/test_init_no_kdf.bats
similarity index 90%
rename from tests/test_init.bats
rename to tests/test_init_no_kdf.bats
index cbae5b2..74f039a 100755
--- a/tests/test_init.bats
+++ b/tests/test_init_no_kdf.bats
@@ -15,20 +15,20 @@ SETUP_SKIP_INIT_TRANSCRYPT=1
 }
 
 @test "init: creates .gitattributes" {
-  init_transcrypt
+  init_transcrypt_no_kdf
   [ -f .gitattributes ]
   run cat .gitattributes
   [ "${lines[0]}" = "#pattern  filter=crypt diff=crypt merge=crypt" ]
 }
 
 @test "init: creates scripts in .git/crypt/" {
-  init_transcrypt
+  init_transcrypt_no_kdf
   [ -d .git/crypt ]
   [ -f .git/crypt/transcrypt ]
 }
 
 @test "init: applies git config" {
-  init_transcrypt
+  init_transcrypt_no_kdf
   VERSION=$(../transcrypt -v | awk '{print $2}')
 
   [ "$(git config --get transcrypt.version)" = "$VERSION" ]
@@ -53,26 +53,26 @@ SETUP_SKIP_INIT_TRANSCRYPT=1
 }
 
 @test "init: show details for --display" {
-  init_transcrypt
+  init_transcrypt_no_kdf
   VERSION=$(../transcrypt -v | awk '{print $2}')
 
   run ../transcrypt --display
   [ "$status" -eq 0 ]
   [[ "${output}" = *"The current repository was configured using transcrypt version $VERSION"* ]]
-  [[ "${output}" = *"  CIPHER:   aes-256-cbc"* ]]
-  [[ "${output}" = *"  PASSWORD: abc 123"* ]]
+  [[ "${output}" = *"  CIPHER:         aes-256-cbc"* ]]
+  [[ "${output}" = *"  PASSWORD:       abc 123"* ]]
   [[ "${output}" = *"  transcrypt -c aes-256-cbc -p 'abc 123'"* ]]
 }
 
 @test "init: show details for -d" {
-  init_transcrypt
+  init_transcrypt_no_kdf
   VERSION=$(../transcrypt -v | awk '{print $2}')
 
   run ../transcrypt -d
   [ "$status" -eq 0 ]
   [[ "${output}" = *"The current repository was configured using transcrypt version $VERSION"* ]]
-  [[ "${output}" = *"  CIPHER:   aes-256-cbc"* ]]
-  [[ "${output}" = *"  PASSWORD: abc 123"* ]]
+  [[ "${output}" = *"  CIPHER:         aes-256-cbc"* ]]
+  [[ "${output}" = *"  PASSWORD:       abc 123"* ]]
   [[ "${output}" = *"  transcrypt -c aes-256-cbc -p 'abc 123'"* ]]
 }
 
@@ -80,7 +80,7 @@ SETUP_SKIP_INIT_TRANSCRYPT=1
   git config core.hooksPath ".git/myhooks"
   [ "$(git config --get core.hooksPath)" = '.git/myhooks' ]
 
-  init_transcrypt
+  init_transcrypt_no_kdf
   [ -d .git/myhooks ]
   [ -f .git/myhooks/pre-commit ]
 
@@ -88,13 +88,13 @@ SETUP_SKIP_INIT_TRANSCRYPT=1
   run ../transcrypt --display
   [ "$status" -eq 0 ]
   [[ "${output}" = *"The current repository was configured using transcrypt version $VERSION"* ]]
-  [[ "${output}" = *"  CIPHER:   aes-256-cbc"* ]]
-  [[ "${output}" = *"  PASSWORD: abc 123"* ]]
+  [[ "${output}" = *"  CIPHER:         aes-256-cbc"* ]]
+  [[ "${output}" = *"  PASSWORD:       abc 123"* ]]
   [[ "${output}" = *"  transcrypt -c aes-256-cbc -p 'abc 123'"* ]]
 }
 
 @test "init: transcrypt.openssl-path config setting defaults to 'openssl'" {
-  init_transcrypt
+  init_transcrypt_no_kdf
   [ "$(git config --get transcrypt.openssl-path)" = 'openssl' ]
 }
 
@@ -104,7 +104,7 @@ SETUP_SKIP_INIT_TRANSCRYPT=1
 }
 
 @test "init: --set-openssl-path is applied during upgrade" {
-  init_transcrypt
+  init_transcrypt_no_kdf
   [ "$(git config --get transcrypt.openssl-path)" = 'openssl' ]
 
   # Set openssl path
@@ -116,7 +116,7 @@ SETUP_SKIP_INIT_TRANSCRYPT=1
 }
 
 @test "init: transcrypt.openssl-path config setting is retained with --upgrade" {
-  init_transcrypt
+  init_transcrypt_no_kdf
   [ "$(git config --get transcrypt.openssl-path)" = 'openssl' ]
 
   # Set openssl path
@@ -136,7 +136,7 @@ SETUP_SKIP_INIT_TRANSCRYPT=1
   # Set a custom location for the crypt/ directory
   git config transcrypt.crypt-dir /tmp/crypt
 
-  init_transcrypt
+  init_transcrypt_no_kdf
 
   # Confirm crypt/ directory is populated in custom location
   [ ! -d .git/crypt ]
@@ -152,7 +152,7 @@ SETUP_SKIP_INIT_TRANSCRYPT=1
   # Set a custom location for the crypt/ directory
   git config transcrypt.crypt-dir /tmp/crypt
 
-  init_transcrypt
+  init_transcrypt_no_kdf
 
   SECRET_CONTENT="My secret content"
   SECRET_CONTENT_ENC="U2FsdGVkX1/6ilR0PmJpAyCF7iG3+k4aBwbgVd48WaQXznsg42nXbQrlWsf/qiCg"
diff --git a/transcrypt b/transcrypt
index 92aae3f..899f814 100755
--- a/transcrypt
+++ b/transcrypt
@@ -8,7 +8,9 @@ set -euo pipefail
 # 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 <aaron@elasticdog.com>
+# Copyright (c) 2020-2023 James Murty <james@murty.co>
+# Copyright (c) 2014-2020 Aaron Bull Schaefer <aaron@elasticdog.com>
+#
 # This source code is provided under the terms of the MIT License
 # that can be be found in the LICENSE file.
 #
@@ -22,14 +24,26 @@ GREP_OPTIONS=""
 ##### CONSTANTS
 
 # the release version of this script
-readonly VERSION='2.3.0-pre'
+readonly VERSION='3.0.0-alpha1'
 
-# the default cipher to utilize
+# the default encryption settings to recommend
 readonly DEFAULT_CIPHER='aes-256-cbc'
+readonly DEFAULT_DIGEST='sha512'
+#readonly DEFAULT_KDF='pbkdf2' # Commented out for now
+# See OWASP PBKDF2 iteration count recommedations:
+# https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2
+readonly DEFAULT_ITERATIONS='256_000'
 
 # context name must match this regexp to ensure it is safe for git config and attrs
 readonly CONTEXT_REGEX='[a-z](-?[a-z0-9])*'
 
+# Encrypted files have the prefix "Salted__" ("U2FsdGVk" in Base64) due to an
+# OpenSSL convention. We rely on this marker to check if a file is already
+# encrypted. Later versions of OpenSSL have dropped this convention, so we must
+# sometimes add the prefix back in (see #133 #147).
+readonly OPENSSL_ENCRYPTED_SALTED_PREFIX='Salted__'
+readonly OPENSSL_ENCRYPTED_SALTED_PREFIX_B64='U2FsdGVk'
+
 ##### FUNCTIONS
 
 # load encryption password
@@ -49,6 +63,23 @@ save_password() {
 	git config "transcrypt${context_config_group}.password" "$password"
 }
 
+# load project salt
+# by default is stored in git config, modify this function to move elsewhere
+load_project_salt() {
+	local context_config_group=${1:-}
+	local project_salt
+	project_salt=$(git config --get --local "transcrypt${context_config_group}.project-salt")
+	echo "$project_salt"
+}
+
+# save project salt
+# by default is stored in git config, modify this function to move elsewhere
+save_project_salt() {
+	local project_salt=$1
+	local context_config_group=${2:-}
+	git config "transcrypt${context_config_group}.project-salt" "$project_salt"
+}
+
 # print a canonicalized absolute pathname
 realpath() {
 	local path=$1
@@ -162,7 +193,8 @@ derive_context_config_group() {
 }
 
 # Detect OpenSSL major version 3 or later which requires a compatibility
-# work-around to include the prefix 'Salted__' and salt value when encrypting.
+# work-around to include the prefix 'Salted__' before the salt value when
+# encrypting.
 #
 # Note that the LibreSSL project's version of the openssl command does NOT
 # require this work-around for major version 3.
@@ -181,79 +213,139 @@ is_salt_prefix_workaround_required() {
 	fi
 }
 
-# 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.
+# Read transcrypt configuration from the Git config and translate the options
+# into a "base" OpenSSL command that can then be used to encrypt or decrypt a
+# file with minimal extra arguments, as appropriate.
+_translate_transcrypt_config_to_openssl_base_arguments() {
+	context=$(extract_context_name_from_name_value_arg "${1:-}")
+	context_config_group=$(derive_context_config_group "$context")
+
+	cipher=$(git config --get --local "transcrypt${context_config_group}.cipher" || printf '')
+	digest=$(git config --get --local "transcrypt${context_config_group}.digest" || printf 'md5') # md5 for legacy compatibility
+	password=$(load_password "$context_config_group")
+
+	kdf=$(git config --get --local "transcrypt${context_config_group}.kdf" || printf '')
+	iterations=$(git config --get --local "transcrypt${context_config_group}.iterations" || printf '')
+	project_salt=$(git config --get --local "transcrypt${context_config_group}.project-salt" || printf '')
+
+	openssl_path=$(git config --get --local transcrypt.openssl-path || printf '')
+
+	# Strip "_" separator characters from iterations value
+	iterations=${iterations//_/}
 
+	# TODO validate $kdf and $digest
+	# TODO assert $iterations and $project_salt are set when $kdf is applied
+
+	_openssl_base_arguments="ENC_PASS=\"${password}\" ${openssl_path} enc -pass env:ENC_PASS -${cipher} -md ${digest} ${kdf:+-${kdf}} ${iterations:+-iter ${iterations}}"
+}
+
+_encrypt_file() {
+	local filepath="$1"
+	local salt="$2"
+
+	if [ "$(is_salt_prefix_workaround_required)" == "true" ]; then
+		# Encrypt the file to base64, ensuring it includes the prefix 'Salted__' with the salt. #133
+		(
+			echo -n "$OPENSSL_ENCRYPTED_SALTED_PREFIX" && echo -n "$salt" | xxd -r -p &&
+				# Encrypt file to binary ciphertext
+				eval "${_openssl_base_arguments} -e -S ${salt} -in ${filepath}"
+		) |
+			openssl base64
+	else
+		# Encrypt file to base64 ciphertext
+		eval "${_openssl_base_arguments} -e -a -S ${salt} -in ${filepath}"
+	fi
+}
+
+##############################################################################
+# 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 and then use
+# the last 16 bytes of that HMAC for the file's unique salt (the openssl
+# standard for salt is 16 hex bytes).
+#
+# The HMAC-SHA256 is keyed with a combination of the filename and...
+# - if a key-derivation function is NOT set, the transcrypt password
+# - if a key-derivation function IS set, a "project salt" value that is set
+#   when the repository is first initialised, from a value provided or by
+#   generating a random value, and stored in the Git config. This project salt
+#   must be provided along with the password to decrypt the repo.
+##############################################################################
+
+# The clean script ENCRYPTS files into the Git index, before they are sent to
+# the remote. The file contents are passed via stdin and the filename is passed
+# as $1 (or $2 if $1 is context name definition).
 git_clean() {
-	context=$(extract_context_name_from_name_value_arg "$1")
-	[[ "$context" ]] && shift
+	_translate_transcrypt_config_to_openssl_base_arguments "$1"
+
+	# We may or may not get a context argument as $1. Filename is $2 if we do.
+	if [[ "$context" ]]; then
+		filename=$2
+	else
+		filename=$1
+	fi
 
-	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)
 	firstbytes=$(head -c8 "$tempfile" | LC_ALL=C tr -d '\0')
-	if [[ $firstbytes == "U2FsdGVk" ]]; then
+	if [[ $firstbytes == "$OPENSSL_ENCRYPTED_SALTED_PREFIX_B64" ]]; then
 		cat "$tempfile"
 	else
-		context_config_group=$(derive_context_config_group "$context")
-		cipher=$(git config --get --local "transcrypt${context_config_group}.cipher")
-		password=$(load_password "$context_config_group")
-		openssl_path=$(git config --get --local transcrypt.openssl-path)
-		salt=$("${openssl_path}" dgst -hmac "${filename}:${password}" -sha256 "$tempfile" | tr -d '\r\n' | tail -c16)
-
-		if [ "$(is_salt_prefix_workaround_required)" == "true" ]; 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
+		# Use "project salt" as extra salt when available (i.e. when a
+		# key-derivation function is set) otherwise the password.
+		extra_salt="${project_salt:-${password}}"
+
+		file_salt=$("${openssl_path}" dgst -hmac "${filename}:${extra_salt}" -sha256 "$tempfile" | tr -d '\r\n' | tail -c16)
+
+		_encrypt_file "$tempfile" "$file_salt"
 	fi
 }
 
+# The smudge script DECRYPTS files when they are checked out to working copy
+# files. The file contents are passed via stdin
 git_smudge() {
 	tempfile=$(mktemp 2>/dev/null || mktemp -t tmp)
 	trap 'rm -f "$tempfile"' EXIT
-	context=$(extract_context_name_from_name_value_arg "$1")
-	context_config_group=$(derive_context_config_group "$context")
-	cipher=$(git config --get --local "transcrypt${context_config_group}.cipher")
-	password=$(load_password "$context_config_group")
-	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"
+
+	_translate_transcrypt_config_to_openssl_base_arguments "${1:-}"
+
+	tee "$tempfile" | eval "${_openssl_base_arguments} -d -a" 2>/dev/null || cat "$tempfile"
 }
 
+# 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.
+# The filename is passed as $1 (or $2 if $1 is context name definition).
 git_textconv() {
-	context=$(extract_context_name_from_name_value_arg "$1")
-	[[ "$context" ]] && shift
+	_translate_transcrypt_config_to_openssl_base_arguments "$1"
+
+	# We may or may not get a context argument as $1. Filename is $2 if we do.
+	if [[ "$context" ]]; then
+		filename=$2
+	else
+		filename=$1
+	fi
 
-	filename=$1
 	# ignore empty files
 	if [[ ! -s $filename ]]; then
 		return
 	fi
 
-	context_config_group=$(derive_context_config_group "$context")
-	cipher=$(git config --get --local "transcrypt${context_config_group}.cipher")
-	password=$(load_password "$context_config_group")
-	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"
+	eval "${_openssl_base_arguments} -d -a -in \"${filename}\"" 2>/dev/null || cat "$filename"
 }
 
 # shellcheck disable=SC2005,SC2002,SC2181
@@ -317,7 +409,7 @@ git_pre_commit() {
 			if [[ $firstbytes == "" ]]; then
 				: # Do nothing
 			# The first bytes of an encrypted file must be "Salted" in Base64
-			elif [[ $firstbytes != "U2FsdGVk" ]]; then
+			elif [[ $firstbytes != "$OPENSSL_ENCRYPTED_SALTED_PREFIX_B64" ]]; 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
@@ -344,7 +436,7 @@ git_pre_commit() {
 		# 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
+		if [[ $firstbytes != "$OPENSSL_ENCRYPTED_SALTED_PREFIX_B64" ]]; then
 			echo "true" >>"${tmp}"
 		fi
 	}
@@ -420,52 +512,144 @@ run_safety_checks() {
 	fi
 }
 
+# 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
+}
+
+# Checks if the target variable is in the set of valid values.  If it is not, it
+# unsets the global 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:\n%s' "$varname" "$varval" "$valid_values")
+		if [[ $interactive ]]; then
+			_set_global "$varname" ""
+			echo "$message"
+			return 1
+		else
+			die 1 "$message"
+		fi
+	fi
+}
+
 # unset the cipher variable if it is not supported by openssl
 validate_cipher() {
-	local list_cipher_commands
+	local valid_ciphers
 	if "${openssl_path}" list-cipher-commands &>/dev/null; then
-		# OpenSSL < v1.1.0
-		list_cipher_commands="${openssl_path} list-cipher-commands"
+		# OpenSSL < v1.1.0 or LibreSSL
+		valid_ciphers=$("${openssl_path}" list-cipher-commands)
 	else
 		# OpenSSL >= v1.1.0
-		list_cipher_commands="${openssl_path} list -cipher-commands"
+		valid_ciphers=$("${openssl_path}" list -cipher-commands)
 	fi
 
-	local supported
-	supported=$($list_cipher_commands | tr -s ' ' '\n' | grep -Fx "$cipher") || true
-	if [[ ! $supported ]]; then
+	# Force global variable value to lowercase
+	cipher=$(echo "${cipher}" | tr '[:upper:]' '[:lower:]')
+
+	_validate_variable_str "cipher" "$valid_ciphers"
+}
+
+validate_digest() {
+	local valid_digests
+	if "${openssl_path}" list-message-digest-commands &>/dev/null; then
+		# LibreSSL
+		# BEWARE: LibreSSL return error code 0 for faulty list commands so we
+		# must try its command variant first to avoid accepting the error
+		# output we would get from the other OpenSSL command variants
+		valid_digests=$("${openssl_path}" list-message-digest-commands)
+	elif "${openssl_path}" list-digest-commands &>/dev/null; then
+		# OpenSSL < v1.1.0
+		valid_digests=$("${openssl_path}" list-digest-commands)
+	elif "${openssl_path}" list -digest-commands &>/dev/null; then
+		# OpenSSL >= v1.1.0
+		valid_digests=$("${openssl_path}" list -digest-commands)
+	fi
+
+	# Force global variable value to lowercase
+	digest=$(echo "${digest}" | tr '[:upper:]' '[:lower:]')
+
+	_validate_variable_str "digest" "$valid_digests"
+}
+
+# Only the "pbkdf2" key-derivation function is currently supported
+validate_kdf() {
+	local valid_kdfs
+	valid_kdfs="pbkdf2"
+
+	# Force global variable value to lowercase
+	kdf=$(echo "${kdf}" | tr '[:upper:]' '[:lower:]')
+
+	_validate_variable_str "kdf" "$valid_kdfs"
+}
+
+# Iteration count must be a positive integer
+validate_iterations() {
+	# Strip "_" separator characters from iterations value
+	local iterations_value
+	iterations_value=${iterations//_/}
+
+	if test "$iterations_value" -gt 0 2>/dev/null; then
+		: # Valid integer > 0
+	else
+		message=$(printf 'iterations is "%s", but must be a positive integer' "$iterations_value")
 		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=''
+			_set_global "$varname" ""
+			echo "$message"
+			return 1
 		else
-			# shellcheck disable=SC2016
-			die 1 '"%s" is not a valid cipher; see `%s`' "$cipher" "$list_cipher_commands"
+			die 1 "$message"
 		fi
 	fi
 }
 
-# ensure we have a cipher to encrypt with
-get_cipher() {
-	while [[ ! $cipher ]]; do
+# 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 'Encrypt using which cipher? [%s] ' "$DEFAULT_CIPHER"
+			printf '%s' "$prompt"
 			read -r answer
 		fi
-
-		# use the default cipher if the user gave no answer;
-		# otherwise verify the given cipher is supported by openssl
+		# 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
-			cipher=$DEFAULT_CIPHER
+			_set_global "$varname" "$default"
 		else
-			cipher=$answer
-			validate_cipher
+			_set_global "$varname" "$answer"
+		fi
+
+		# Run validate function if provided.
+		if [[ $validate_fn ]]; then
+			# In interactive mode, failed validation just clears the variable
+			# to permit re-entry within the while loop
+			if [[ $interactive ]]; then
+				${validate_fn} || _set_global "$varname" ""
+			# In non-interactive mode, failed validation causes script to die
+			else
+				${validate_fn} || die "Invalid setting"
+			fi
 		fi
 	done
 }
 
+# sets a bash global variable by name
+_set_global() {
+	key=$1
+	val=$2
+	printf -v "$key" '%s' "$val"
+}
+
 # ensure we have a password to encrypt with
 get_password() {
 	while [[ ! $password ]]; do
@@ -491,6 +675,30 @@ get_password() {
 	done
 }
 
+# ensure we have a project salt to encrypt with when using a key-derivation function
+get_project_salt() {
+	while [[ ! $project_salt ]]; do
+		printf 'Projects using a key-derivation function require a project salt\n'
+		local answer=
+		if [[ $interactive ]]; then
+			printf 'Generate a random project salt? [Y/n] '
+			read -r -n 1 -s answer
+			printf '\n'
+		fi
+
+		# generate a random project salt value if the user answered yes;
+		# otherwise prompt the user for the hex salt value
+		if [[ $answer =~ $YES_REGEX ]] || [[ ! $answer ]]; then
+			local project_salt_length=18 # 18 bytes gives a 25 char B64 string
+			project_salt=$(${openssl_path} rand -base64 $project_salt_length)
+		else
+			printf 'Project salt: '
+			read -r project_salt
+			[[ $project_salt ]] || printf 'no project salt was specified\n'
+		fi
+	done
+}
+
 # confirm the transcrypt configuration
 confirm_configuration() {
 	local answer=
@@ -500,9 +708,13 @@ confirm_configuration() {
 	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 '  CONTEXT:  %s\n' "$CONTEXT"
-	printf '  CIPHER:   %s\n' "$cipher"
-	printf '  PASSWORD: %s\n\n' "$password"
+	printf '  CONTEXT:        %s\n' "$CONTEXT"
+	printf '  CIPHER:         %s\n' "$cipher"
+	[[ "${digest:-}" ]] && printf '  DIGEST:         %s\n' "$digest"
+	[[ "${kdf:-}" ]] && printf '  KDF:            %s\n' "$kdf"
+	[[ "${iterations:-}" ]] && printf '  ITERATIONS:     %s\n' "$iterations"
+	[[ "${project_salt:-}" ]] && printf '  PROJECT SALT:   %s\n' "$project_salt"
+	printf '  PASSWORD:       %s\n\n' "$password"
 	printf 'Does this look correct? [Y/n] '
 	read -r -n 1 -s answer
 
@@ -524,9 +736,13 @@ confirm_rekey() {
 	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 '  CONTEXT:  %s\n' "$CONTEXT"
-	printf '  CIPHER:   %s\n' "$cipher"
-	printf '  PASSWORD: %s\n\n' "$password"
+	printf '  CONTEXT:        %s\n' "$CONTEXT"
+	printf '  CIPHER:         %s\n' "$cipher"
+	[[ "${digest:-}" ]] && printf '  DIGEST:         %s\n' "$digest"
+	[[ "${kdf:-}" ]] && printf '  KDF:            %s\n' "$kdf"
+	[[ "${iterations:-}" ]] && printf '  ITERATIONS:     %s\n' "$iterations"
+	[[ "${project_salt:-}" ]] && printf '  PROJECT SALT:   %s\n' "$project_salt"
+	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] '
@@ -631,7 +847,16 @@ save_configuration() {
 	# write the encryption info
 	git config transcrypt.version "$VERSION"
 	git config "transcrypt${CONTEXT_CONFIG_GROUP}.cipher" "$cipher"
+	[[ ${digest:-} ]] && git config "transcrypt${CONTEXT_CONFIG_GROUP}.digest" "$digest"
+	[[ ${kdf:-} ]] && git config "transcrypt${CONTEXT_CONFIG_GROUP}.kdf" "$kdf"
+	[[ ${iterations:-} ]] && git config "transcrypt${CONTEXT_CONFIG_GROUP}.iterations" "$iterations"
 	save_password "$password" "$CONTEXT_CONFIG_GROUP"
+
+	# if a key derivation function is specified, generate a project salt to use when encrypting files
+	if [[ ${kdf:-} ]]; then
+		save_project_salt "$project_salt" "$CONTEXT_CONFIG_GROUP"
+	fi
+
 	git config transcrypt.openssl-path "$openssl_path"
 
 	# write the filter settings. Sorry for the horrific quote escaping below...
@@ -673,8 +898,17 @@ save_configuration() {
 
 # display the current configuration settings
 display_configuration() {
-	local current_cipher
-	current_cipher=$(git config --get --local "transcrypt${CONTEXT_CONFIG_GROUP}.cipher")
+	local cipher
+	cipher=$(git config --get --local "transcrypt${CONTEXT_CONFIG_GROUP}.cipher")
+	local digest
+	digest=$(git config --get --local "transcrypt${CONTEXT_CONFIG_GROUP}.digest" || printf '')
+	local kdf
+	kdf=$(git config --get --local "transcrypt${CONTEXT_CONFIG_GROUP}.kdf" || printf '')
+	local iterations
+	iterations=$(git config --get --local "transcrypt${CONTEXT_CONFIG_GROUP}.iterations" || printf '')
+
+	local project_salt
+	project_salt=$(load_project_salt "$CONTEXT_CONFIG_GROUP")
 	local current_password
 	current_password=$(load_password "$CONTEXT_CONFIG_GROUP")
 	local escaped_password=${current_password//\'/\'\\\'\'}
@@ -686,18 +920,22 @@ display_configuration() {
 	[[ ! $REPO ]] || printf '  GIT_WORK_TREE:  %s\n' "$REPO"
 	printf '  GIT_DIR:        %s\n' "$GIT_DIR"
 	printf '  GIT_ATTRIBUTES: %s\n\n' "$GIT_ATTRIBUTES"
-	printf '  CONTEXT:  %s\n' "$CONTEXT"
-	printf '  CIPHER:   %s\n' "$current_cipher"
-	printf '  PASSWORD: %s\n\n' "$current_password"
+	printf '  CONTEXT:        %s\n' "$CONTEXT"
+	printf '  CIPHER:         %s\n' "$cipher"
+	[[ "${digest:-}" ]] && printf '  DIGEST:         %s\n' "$digest"
+	[[ "${kdf:-}" ]] && printf '  KDF:            %s\n' "$kdf"
+	[[ "${iterations:-}" ]] && printf '  ITERATIONS:     %s\n' "$iterations"
+	[[ "${project_salt:-}" ]] && printf '  PROJECT SALT:   %s\n' "$project_salt"
+	printf '  PASSWORD:       %s\n\n' "$current_password"
 	if [[ "$contexts_count" -gt "1" ]]; then
 		printf 'The repository has %s contexts: %s\n\n' "$contexts_count" "$CONFIGURED_CONTEXTS"
 	fi
 	printf "Copy and paste the following command to initialize a cloned repository%s:\n\n" "$CONTEXT_DESCRIPTION"
 	if [[ $CONTEXT != 'default' ]]; then
-		printf "  transcrypt -C $CONTEXT -c %s -p '%s'\n" "$current_cipher" "$escaped_password"
-	else
-		printf "  transcrypt -c %s -p '%s'\n" "$current_cipher" "$escaped_password"
+		context_extra=" -C $CONTEXT"
 	fi
+
+	printf "  transcrypt${context_extra:-} -c ${cipher}${digest:+ -md ${digest}}${kdf:+ -k ${kdf}}${iterations:+ -n ${iterations}}${project_salt:+ -ps ${project_salt}} -p '%s'\n" "$escaped_password"
 }
 
 # remove transcrypt-related settings from the repository's git config
@@ -821,9 +1059,9 @@ uninstall_transcrypt() {
 		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
+			hook_sha1=$("${openssl_path}" sha1 -hex <"$pre_commit_hook")
+			installed_sha1=$("${openssl_path}" sha1 -hex <"$pre_commit_hook_installed")
+			if [[ "$hook_sha1" = "$installed_sha1" ]]; then
 				rm "$pre_commit_hook"
 			else
 				printf 'WARNING: Cannot safely disable Git pre-commit hook %s please check it yourself\n' "$pre_commit_hook"
@@ -903,6 +1141,8 @@ upgrade_transcrypt() {
 		fi
 	fi
 
+	# TODO Retain new PBKDF2 settings across upgrade
+
 	# Keep current cipher and password
 	cipher=$(git config --get --local "transcrypt${CONTEXT_CONFIG_GROUP}.cipher")
 	password=$(load_password "$CONTEXT_CONFIG_GROUP")
@@ -1170,6 +1410,25 @@ help() {
 		            the symmetric cipher to utilize for encryption;
 		            defaults to aes-256-cbc
 
+		     -md, --digest=DIGEST
+		            the message digest used to hash the salted password;
+		            defaults to sha512
+		            Use md5 for compatibility with transcrypt versions < 3
+
+		     -k, --kdf=KEY_DERIVATION_FUNCTION
+		            a key-derivation function to use for strongest encryption;
+		            defaults to pbkdf2
+		            If enabled, all users will need Transcrypt 3+ and modern OpenSSL
+
+		     -n, --iter=ITERATIONS
+		            when using a key-derivation function, its number of iterations;
+		            defaults to 256_000
+
+		     -ps, --salt=PROJECT_SALT
+		            when using a key-derivation function, an extra value to
+		            strengthen per-file salt values;
+		            defaults to 18 random base64 characters
+
 		     -p, --password=PASSWORD
 		            the password to derive the key from;
 		            defaults to 30 random base64 characters
@@ -1313,11 +1572,13 @@ gpg_import_file=''
 gpg_recipient=''
 interactive='true'
 list=''
+project_salt=''
 password=''
 rekey=''
 show_file=''
 uninstall=''
 upgrade=''
+set_openssl_path=''
 openssl_path='openssl'
 
 # used to bypass certain safety checks
@@ -1359,6 +1620,34 @@ while [[ "${1:-}" != '' ]]; do
 	--cipher=*)
 		cipher=${1#*=}
 		;;
+	-md | --digest)
+		digest=$2
+		shift
+		;;
+	--digest=*)
+		digest=${1#*=}
+		;;
+	-k | --kdf)
+		kdf=${2}
+		shift
+		;;
+	--kdf=*)
+		kdf=${1#*=}
+		;;
+	-n | --iter)
+		iterations=${2}
+		shift
+		;;
+	--iter=*)
+		iterations=${1#*=}
+		;;
+	-ps | --salt)
+		project_salt=${2}
+		shift
+		;;
+	--salt=*)
+		project_salt=${1#*=}
+		;;
 	-p | --password)
 		password=$2
 		shift
@@ -1374,6 +1663,7 @@ while [[ "${1:-}" != '' ]]; do
 		context=${1#*=}
 		;;
 	--set-openssl-path=*)
+		set_openssl_path='true'
 		openssl_path=${1#*=}
 		# Immediately apply config setting
 		git config transcrypt.openssl-path "$openssl_path"
@@ -1522,10 +1812,58 @@ elif [[ $gpg_import_file ]]; then
 	import_gpg
 elif [[ $cipher ]]; then
 	validate_cipher
+elif [[ ${digest:-} ]]; then
+	validate_digest
+elif [[ ${kdf:-} ]]; then
+	validate_kdf
+elif [[ ${iterations:-} ]]; then
+	validate_iterations
+fi
+
+# Try to detect when user is initialising transcrypt using a command format
+# that does not specify a KDF or related settings, e.g. from before version 3.
+if [[ ${cipher:-} ]] && [[ ${password:-} ]] && [[ -z ${digest:-} ]] && [[ -z ${kdf:-} ]] && [[ -z ${iterations:-} ]]; then
+	is_no_kdf_init_command='true'
+else
+	is_no_kdf_init_command=''
 fi
 
 # perform function calls to configure transcrypt
-get_cipher
+_get_user_input cipher "$DEFAULT_CIPHER" "validate_cipher" \
+	"$(printf 'Encrypt using which cipher? [%s] ' "$DEFAULT_CIPHER")"
+
+# Prompt user for encryption settings available since version 3, but only if:
+# - it doesn't seem like they are using a legacy init command format, or just
+#   one that doesn't specify a KDF
+# - the user isn't just setting the openssl-path
+if [[ "$is_no_kdf_init_command" == "" ]] && [[ -z ${set_openssl_path:-} ]]; then
+	_get_user_input digest "$DEFAULT_DIGEST" "validate_digest" \
+		"$(printf 'Encrypt using which digest? [%s] ' "$DEFAULT_DIGEST")"
+
+	if [[ -z ${kdf:-} ]]; then
+		printf 'Use the PBKDF2 key derivation function for best security?\n'
+		printf ' * Requires Transcrypt 3+ and modern OpenSSL *\n'
+		printf '[Y/n] '
+		read -r -n 1 -s answer
+		printf '\n'
+	else
+		answer='y'
+	fi
+
+	if [[ $answer =~ $YES_REGEX ]] || [[ ! $answer ]]; then
+		# Only KDF currently supported is PBKDF2 so no need to prompt for it
+		kdf='pbkdf2'
+
+		# _get_user_input kdf "$DEFAULT_KDF" "validate_kdf" \
+		# 	"$(printf 'Which key derivation function? [%s] ' "$DEFAULT_KDF")"
+
+		_get_user_input iterations "$DEFAULT_ITERATIONS" "validate_iterations" \
+			"$(printf 'How many iterations? Use "_" separators for clarity [%s] ' "$DEFAULT_ITERATIONS")"
+
+		get_project_salt
+	fi
+fi
+
 get_password
 
 if [[ $rekey ]] && [[ $interactive ]]; then