#!/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 # # For macOS coreutils and gnu-getopt are required to # run this script. # brew install coreutils gnu-getopt # #--------------------------------------------------- #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}" # 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/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}" local identity_file="$(mktemp -u -p ${TMPPATH})" # 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}) " 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-)" 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-)" 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 ! PARSED=$(getopt --options=${OPTIONS} --longoptions=${LONGOPTS} --name "$(basename ${0})" -- "${@:--h}") 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