2023-12-15 14:35:29 +00:00
|
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
# More safety, by turning some bugs into errors.
|
|
|
|
|
# Without `errexit` you don’t need ! and can replace
|
|
|
|
|
# ${PIPESTATUS[0]} with a simple $?, but I prefer safety.
|
|
|
|
|
set -euf -o pipefail
|
|
|
|
|
|
|
|
|
|
#---------------------------------------------------
|
|
|
|
|
#
|
|
|
|
|
# {{@@ header() @@}}
|
|
|
|
|
#
|
|
|
|
|
# age encryption / decryption helpers
|
|
|
|
|
# based on https://git.sr.ht/~digital/secretFiles
|
|
|
|
|
#
|
2024-01-11 21:38:01 +00:00
|
|
|
|
# For macOS coreutils and gnu-getopt are required to
|
|
|
|
|
# run this script.
|
|
|
|
|
# brew install coreutils gnu-getopt
|
|
|
|
|
#
|
2023-12-15 14:35:29 +00:00
|
|
|
|
#---------------------------------------------------
|
|
|
|
|
|
2024-01-11 21:38:01 +00:00
|
|
|
|
#TMPPATH="/dev/shm"
|
|
|
|
|
TMPPATH="/tmp"
|
|
|
|
|
|
|
|
|
|
[[ -d "/opt/homebrew/opt/coreutils/libexec/gnubin" ]] && export PATH="/opt/homebrew/opt/coreutils/libexec/gnubin:${PATH}"
|
|
|
|
|
[[ -d "/opt/homebrew/opt/gnu-getopt/bin" ]] && export PATH="/opt/homebrew/opt/gnu-getopt/bin:${PATH}"
|
|
|
|
|
|
2023-12-15 14:35:29 +00:00
|
|
|
|
# get recipients for age file to encrypt with
|
|
|
|
|
get-recipients-list() {
|
|
|
|
|
local target="${1}"
|
|
|
|
|
local recipients=${2:-"-R" "$(pwd)/secrets/hostkeys/masterkey.pubkey"}
|
|
|
|
|
local search="${target}"
|
|
|
|
|
while true; do
|
|
|
|
|
if test -d "${search}.recipients"; then
|
|
|
|
|
for recip in $(ls ${search}.recipients) ; do
|
|
|
|
|
if test -n "${recip}"; then
|
|
|
|
|
recipients+=("-R" "${search}.recipients/${recip}")
|
|
|
|
|
fi
|
|
|
|
|
done
|
|
|
|
|
elif test -f "${search}.recipients"; then
|
|
|
|
|
recipients+=( "-R" "${search}.recipients")
|
|
|
|
|
fi
|
|
|
|
|
if test "$(realpath ${search})" == "$(realpath $(pwd))"; then
|
|
|
|
|
break
|
|
|
|
|
fi
|
|
|
|
|
search=$(dirname "${search}")
|
|
|
|
|
done
|
|
|
|
|
echo "${recipients[@]}"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
gen-key() {
|
|
|
|
|
local keyname="${1}"
|
|
|
|
|
local working_directory="${2:-$(pwd)}"
|
|
|
|
|
|
|
|
|
|
mkdir -p "${working_directory}/secrets/hostkeys/"
|
|
|
|
|
echo "generating new keys for host ${keyname}";
|
|
|
|
|
age-keygen \
|
|
|
|
|
2> "${working_directory}/secrets/hostkeys/${keyname}.pubkey" \
|
|
|
|
|
| age -p --armor -e -o "${working_directory}/secrets/hostkeys/${keyname}.privkey"
|
|
|
|
|
sed -i 's/Public key: //' "${working_directory}/secrets/hostkeys/${keyname}.pubkey"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
import-secret() {
|
|
|
|
|
# local stdin=$(</dev/stdin)
|
|
|
|
|
|
|
|
|
|
local working_directory="${3:-$(pwd)}"
|
|
|
|
|
local secret_path="${working_directory}/${2}"
|
|
|
|
|
if [[ "${1}" == "EMPTY" ]]; then
|
|
|
|
|
local data=$(</dev/stdin)
|
|
|
|
|
fi
|
|
|
|
|
local recipients_list=$(get-recipients-list "${secret_path}")
|
|
|
|
|
local dirname="$(dirname ${secret_path})"
|
|
|
|
|
local identity="${MASTERKEY_FILE:-secrets/hostkeys/masterkey.privkey}"
|
|
|
|
|
|
|
|
|
|
mkdir -p "${dirname}"
|
|
|
|
|
|
|
|
|
|
if [[ "${1}" == "EMPTY" ]]; then
|
|
|
|
|
echo -n ${data} | age $(sed -e "s/^\'//" -e "s/\'$//" <<<"${recipients_list[@]}") --encrypt --armor --output "${secret_path}"
|
|
|
|
|
else
|
|
|
|
|
age $(sed -e "s/^\'//" -e "s/\'$//" <<<"${recipients_list[@]}") --encrypt --armor --output "${secret_path}" "${working_directory}/${1}"
|
|
|
|
|
fi
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
edit-file() {
|
|
|
|
|
local current_umask=$(umask)
|
|
|
|
|
umask 177
|
|
|
|
|
|
|
|
|
|
local working_directory="${2:-$(pwd)}"
|
|
|
|
|
local secret_path="${working_directory}/${1}"
|
2024-01-11 21:38:01 +00:00
|
|
|
|
local tmp_path="$(mktemp -p ${TMPPATH})"
|
2023-12-15 14:35:29 +00:00
|
|
|
|
local recipients_list=$(get-recipients-list "${secret_path}")
|
|
|
|
|
local identity="${MASTERKEY_FILE:-$([[ -f "$(realpath "${working_directory}/secrets/hostkeys/masterkey.privkey")" ]] && echo -n "$(realpath "${working_directory}/secrets/hostkeys/masterkey.privkey")" || echo -n "/dev/stdin")}"
|
|
|
|
|
|
|
|
|
|
if test -e "${secret_path}"; then
|
|
|
|
|
set +e +o pipefail
|
|
|
|
|
|
|
|
|
|
age \
|
|
|
|
|
--decrypt \
|
|
|
|
|
--identity "${identity}" \
|
|
|
|
|
--output "${tmp_path}" \
|
|
|
|
|
"${secret_path}" || local decrypt_failed=true
|
|
|
|
|
|
|
|
|
|
set -e -o pipefail
|
|
|
|
|
else
|
|
|
|
|
# if file descriptor 0 is not a terminal, ie if /dev/stdin is a pipe
|
|
|
|
|
if [ ! -t 0 ]; then
|
|
|
|
|
cat "${identity}" > /dev/null
|
|
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
if [[ ! ${decrypt_failed:-} ]]; then
|
|
|
|
|
local mod_time_before=$(stat --format "%Y" "${tmp_path}")
|
|
|
|
|
${EDITOR} "${tmp_path}"
|
|
|
|
|
local mod_time_after=$(stat --format "%Y" "${tmp_path}")
|
|
|
|
|
|
|
|
|
|
if test "${mod_time_before}" != "${mod_time_after}"; then
|
|
|
|
|
echo "change detected, reencrypting file" > /dev/stderr
|
|
|
|
|
age $(sed -e "s/^\'//" -e "s/\'$//" <<<"${recipients_list[@]}") --encrypt --armor --output "${secret_path}" "${tmp_path}"
|
|
|
|
|
else
|
|
|
|
|
echo "no change detected, not reencrypting file" > /dev/stderr
|
|
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
rm "${tmp_path}"
|
|
|
|
|
|
|
|
|
|
umask ${current_umask}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
reencrypt-all() {
|
|
|
|
|
local current_umask=$(umask)
|
|
|
|
|
umask 177
|
|
|
|
|
|
|
|
|
|
local working_directory="${2:-$(pwd)}"
|
|
|
|
|
local identity="${1:-/dev/stdin}"
|
2024-01-11 21:38:01 +00:00
|
|
|
|
local identity_file="$(mktemp -u -p ${TMPPATH})"
|
2023-12-15 14:35:29 +00:00
|
|
|
|
|
|
|
|
|
# make the identity file reuseable, in case it actually is /dev/stdin
|
|
|
|
|
umask 177
|
|
|
|
|
cat "${identity}" > "${identity_file}"
|
|
|
|
|
|
|
|
|
|
cd ${working_directory}
|
|
|
|
|
find "secrets" -type f -not -name "*\.recipients" \
|
|
|
|
|
| grep -v "^secrets/hostkeys/"| while read line; do
|
|
|
|
|
if ! grep -q "^-----BEGIN AGE ENCRYPTED FILE-----$" "${line}"; then
|
|
|
|
|
echo "skipping unecrypted file '${line}'"
|
|
|
|
|
continue
|
|
|
|
|
fi
|
|
|
|
|
local recipients=$(get-recipients-list "${line}")
|
|
|
|
|
echo "reencrypting '${line}' for recipients ${recipients[@]}"
|
|
|
|
|
local content="$(age --decrypt \
|
|
|
|
|
--identity "${identity_file}" \
|
|
|
|
|
"${line}" \
|
|
|
|
|
)" || {
|
|
|
|
|
echo "ERROR: failed decryption of '${line}'" > /dev/stderr
|
|
|
|
|
echo "aborting and leaving secrets store in an inconsistent state" > /dev/stderr
|
|
|
|
|
exit 2
|
|
|
|
|
}
|
|
|
|
|
if test $? -eq 0 ; then
|
|
|
|
|
echo -n "${content}" \
|
|
|
|
|
| age $(sed -e "s/^\'//" -e "s/\'$//" <<<"${recipients[@]}") \
|
|
|
|
|
--encrypt \
|
|
|
|
|
--armor \
|
|
|
|
|
--output "${line}"
|
|
|
|
|
fi
|
|
|
|
|
done
|
|
|
|
|
|
|
|
|
|
rm "${identity_file}"
|
|
|
|
|
|
|
|
|
|
umask ${current_umask}
|
|
|
|
|
|
|
|
|
|
echo "SUCCESS" > /dev/stderr
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pass-import-key() {
|
|
|
|
|
local keyname="${1}"
|
|
|
|
|
local passbase="${2:-nixfiles/hostkeys}/${keyname}"
|
|
|
|
|
local working_directory="${3:-$(pwd)}"
|
|
|
|
|
local secretbase="${working_directory}/secrets/hostkeys/${keyname}"
|
|
|
|
|
|
|
|
|
|
if test ! -f "${secretbase}.privkey"; then
|
|
|
|
|
echo "missing private key file for key ${keyname}"
|
|
|
|
|
exit 1
|
|
|
|
|
elif test ! -f "${secretbase}.pubkey"; then
|
|
|
|
|
echo "missing public key file for key ${keyname}"
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
echo "importing the keyfiles for host ${keyname}"
|
|
|
|
|
echo "enter the password for the private key file"
|
|
|
|
|
pass insert "${passbase}.pw"
|
|
|
|
|
pass -c "${passbase}.pw"
|
|
|
|
|
echo "enter the password for the private key file again"
|
|
|
|
|
age -d "${secretbase}.privkey" | pass insert -m "${passbase}.privkey" > /dev/null
|
|
|
|
|
cat "${secretbase}.pubkey" | pass insert -m "${passbase}.pubkey" > /dev/null
|
|
|
|
|
echo "success"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
help() {
|
|
|
|
|
echo "Usage: $(basename ${0}) <edit | gen-key | import | pass-import-key | reencrypt-all>"
|
|
|
|
|
echo ""
|
|
|
|
|
echo "Options:"
|
|
|
|
|
echo " edit"
|
|
|
|
|
echo " -f, --file relative path to the nixOS root directory to the file"
|
|
|
|
|
echo " -p, --path path to the root directory for the nixOS configuration files, defaults to \`pwd\`"
|
|
|
|
|
echo " gen-key"
|
|
|
|
|
echo " -k, --key keyname, usually the hostname (e.g. host-<hostname>)"
|
|
|
|
|
echo " -p, --path path to the root directory for the nixOS configuration files, defaults to \`pwd\`"
|
|
|
|
|
echo " import"
|
|
|
|
|
echo " -f, --file relative path to the nixOS root directory to the file which should be imported"
|
|
|
|
|
echo " Instead of using this option to reference a file, you can also pass the input via \`stdin\`"
|
|
|
|
|
echo " -o, --output relative path to the nixOS root directory where the encrypted secret will be stored"
|
|
|
|
|
echo " -p, --path path to the root directory for the nixOS configuration files, defaults to \`pwd\`"
|
|
|
|
|
echo " pass-import-key"
|
|
|
|
|
echo " -k, --key keyname, usually the hostname (e.g. host-<hostname>)"
|
|
|
|
|
echo " -b, --passbase base path in pass for stored secret, defaults to \`nixfiles/hostkeys\`"
|
|
|
|
|
echo " -p, --path path to the root directory for the nixOS configuration files, defaults to \`pwd\`"
|
|
|
|
|
echo " reencrypt-all"
|
|
|
|
|
echo " -i, --identity identity / age private key to DECRYPT the secret for reencryption"
|
|
|
|
|
echo " -p, --path path to the root directory for the nixOS configuration files, defaults to \`pwd\`"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# -allow a command to fail with !’s side effect on errexit
|
|
|
|
|
# -use return value from ${PIPESTATUS[0]}, because ! hosed $?
|
|
|
|
|
! getopt --test > /dev/null
|
|
|
|
|
if [[ ${PIPESTATUS[0]} -ne 4 ]]; then
|
|
|
|
|
echo 'I’m sorry, `getopt --test` failed in this environment.'
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
# option --output/-o requires 1 argument
|
|
|
|
|
OPTIONS=b:f:hi:k:o:p:
|
|
|
|
|
LONGOPTS=passbase:,file:,help,identity:,key:,output:,path:
|
|
|
|
|
|
|
|
|
|
# -regarding ! and PIPESTATUS see above
|
|
|
|
|
# -temporarily store output to be able to check for errors
|
|
|
|
|
# -activate quoting/enhanced mode (e.g. by writing out “--options”)
|
|
|
|
|
# -pass arguments only via -- "$@" to separate them correctly
|
2024-01-18 20:23:12 +00:00
|
|
|
|
! PARSED=$(getopt --options=${OPTIONS} --longoptions=${LONGOPTS} --name "$(basename ${0})" -- "${@:--h}")
|
2023-12-15 14:35:29 +00:00
|
|
|
|
if [[ ${PIPESTATUS[0]} -ne 0 ]]; then
|
|
|
|
|
# e.g. return value is 1
|
|
|
|
|
# then getopt has complained about wrong arguments to stdout
|
|
|
|
|
exit 2
|
|
|
|
|
fi
|
|
|
|
|
# read getopt’s output this way to handle the quoting right:
|
|
|
|
|
eval set -- "${PARSED}"
|
|
|
|
|
|
|
|
|
|
# now enjoy the options in order and nicely split until we see --
|
|
|
|
|
while true; do
|
|
|
|
|
case "${1}" in
|
|
|
|
|
-b|--passbase)
|
|
|
|
|
passbase="${2}"
|
|
|
|
|
shift 2
|
|
|
|
|
;;
|
|
|
|
|
-f|--file)
|
|
|
|
|
file="${2}"
|
|
|
|
|
shift 2
|
|
|
|
|
;;
|
|
|
|
|
-h|--help)
|
|
|
|
|
shift
|
|
|
|
|
help
|
|
|
|
|
exit
|
|
|
|
|
;;
|
|
|
|
|
-i|--identity)
|
|
|
|
|
identity="${2}"
|
|
|
|
|
shift 2
|
|
|
|
|
;;
|
|
|
|
|
-k|--key)
|
|
|
|
|
key="${2}"
|
|
|
|
|
shift 2
|
|
|
|
|
;;
|
|
|
|
|
-o|--output)
|
|
|
|
|
output="${2}"
|
|
|
|
|
shift 2
|
|
|
|
|
;;
|
|
|
|
|
-p|--path)
|
|
|
|
|
path="${2}"
|
|
|
|
|
shift 2
|
|
|
|
|
;;
|
|
|
|
|
--)
|
|
|
|
|
shift
|
|
|
|
|
break
|
|
|
|
|
;;
|
|
|
|
|
*)
|
|
|
|
|
echo "This option (${1}) does not exist. Exiting."
|
|
|
|
|
exit 3
|
|
|
|
|
;;
|
|
|
|
|
esac
|
|
|
|
|
done
|
|
|
|
|
|
|
|
|
|
# handle non-option arguments
|
|
|
|
|
if [[ ${#} -eq 1 ]]; then
|
|
|
|
|
while true; do
|
|
|
|
|
case "${1}" in
|
|
|
|
|
edit)
|
|
|
|
|
edit-file "${file:?Error, missing option \"-f\"}" "${path:-}"
|
|
|
|
|
shift
|
|
|
|
|
exit
|
|
|
|
|
;;
|
|
|
|
|
gen-key)
|
|
|
|
|
gen-key "${key:?Error, missing option \"-k\"}" "${path:-}"
|
|
|
|
|
shift
|
|
|
|
|
exit
|
|
|
|
|
;;
|
|
|
|
|
import)
|
|
|
|
|
import-secret "${file:-"EMPTY"}" "${output:?Error, missing option \"-o\"}" "${path:-}"
|
|
|
|
|
shift
|
|
|
|
|
exit
|
|
|
|
|
;;
|
|
|
|
|
pass-import-key)
|
|
|
|
|
pass-import-key "${key:?Error, missing option \"-k\"}" "${passbase:?Error, missing option \"-b\"}" "${path:-}"
|
|
|
|
|
shift
|
|
|
|
|
exit
|
|
|
|
|
;;
|
|
|
|
|
reencrypt-all)
|
|
|
|
|
reencrypt-all "${identity:?Error, missing option \"-i\"}" "${path:-}"
|
|
|
|
|
shift
|
|
|
|
|
exit
|
|
|
|
|
;;
|
|
|
|
|
*)
|
|
|
|
|
echo "Wrong sub command, use -h to print the help."
|
|
|
|
|
exit 4
|
|
|
|
|
;;
|
|
|
|
|
esac
|
|
|
|
|
done
|
|
|
|
|
else
|
|
|
|
|
echo "No sub command provided, use -h to print the help."
|
|
|
|
|
fi
|