Skip to content

Instantly share code, notes, and snippets.

@arrjay
Created November 22, 2023 16:43
Show Gist options
  • Save arrjay/a0452a805e113350daa85991fd8976d9 to your computer and use it in GitHub Desktop.
Save arrjay/a0452a805e113350daa85991fd8976d9 to your computer and use it in GitHub Desktop.
pass extension for gpg shenanigans
#!/bin/bash
# Created by argbash-init v2.10.0
# Rearrange the order of options below according to what you would like to see in the help message.
# ARG_OPTIONAL_SINGLE([id],[u],[gpg/pass id to use],[])
# ARG_OPTIONAL_SINGLE([name],[N],[gpg identity name for creation],[])
# ARG_OPTIONAL_SINGLE([email],[E],[gpg identity email for creation],[])
# ARG_OPTIONAL_SINGLE([ciphertype],[C],[gpg key cipher type (ecc/rsa)],[ecc])
# ARG_OPTIONAL_SINGLE([passlength],[L],[password length for generated passphrase],[32])
# ARG_OPTIONAL_BOOLEAN([nosym],[X],[exclude symbols in generated password],[off])
# ARG_OPTIONAL_SINGLE([exec],[c],[for exec/run/shell, execute command string],[$SHELL -i])
# ARG_OPTIONAL_SINGLE([storepath],[S],[default storage path for generated secrets],[s/gpg])
# ARG_OPTIONAL_BOOLEAN([identlink],[I],[create symlinks to track GPG identities easier],[on])
# ARG_POSITIONAL_INF([verb],[action],[0])
# ARG_DEFAULTS_POS([])
# ARG_HELP([gpg (key) operations for password-store])
# ARGBASH_GO()
# needed because of Argbash --> m4_ignore([
### START OF CODE GENERATED BY Argbash v2.10.0 one line above ###
# Argbash is a bash code generator used to get arguments parsing right.
# Argbash is FREE SOFTWARE, see https://argbash.io for more info
die()
{
local _ret="${2:-1}"
test "${_PRINT_HELP:-no}" = yes && print_help >&2
echo "$1" >&2
exit "${_ret}"
}
begins_with_short_option()
{
local first_option all_short_options='uNECLXcSIh'
first_option="${1:0:1}"
test "$all_short_options" = "${all_short_options/$first_option/}" && return 1 || return 0
}
# THE DEFAULTS INITIALIZATION - POSITIONALS
_positionals=()
_arg_verb=()
# THE DEFAULTS INITIALIZATION - OPTIONALS
_arg_id=
_arg_name=
_arg_email=
_arg_ciphertype="ecc"
_arg_passlength="32"
_arg_nosym="off"
_arg_exec="$SHELL -i"
_arg_storepath="s/gpg"
_arg_identlink="on"
print_help()
{
printf '%s\n' "gpg (key) operations for password-store"
printf 'Usage: %s [-u|--id <arg>] [-N|--name <arg>] [-E|--email <arg>] [-C|--ciphertype <arg>] [-L|--passlength <arg>] [-X|--(no-)nosym] [-c|--exec <arg>] [-S|--storepath <arg>] [-I|--(no-)identlink] [-h|--help] [<verb-1>] ... [<verb-n>] ...\n' "$0"
printf '\t%s\n' "<verb>: action"
printf '\t%s\n' "-u, --id: gpg/pass id to use (no default)"
printf '\t%s\n' "-N, --name: gpg identity name for creation (no default)"
printf '\t%s\n' "-E, --email: gpg identity email for creation (no default)"
printf '\t%s\n' "-C, --ciphertype: gpg key cipher type (ecc/rsa) (default: 'ecc')"
printf '\t%s\n' "-L, --passlength: password length for generated passphrase (default: '32')"
printf '\t%s\n' "-X, --nosym, --no-nosym: exclude symbols in generated password (off by default)"
printf '\t%s\n' "-c, --exec: for exec/run/shell, execute command string (default: '$SHELL -i')"
printf '\t%s\n' "-S, --storepath: default storage path for generated secrets (default: 's/gpg')"
printf '\t%s\n' "-I, --identlink, --no-identlink: create symlinks to track GPG identities easier (on by default)"
printf '\t%s\n' "-h, --help: Prints help"
}
parse_commandline()
{
_positionals_count=0
while test $# -gt 0
do
_key="$1"
case "$_key" in
-u|--id)
test $# -lt 2 && die "Missing value for the optional argument '$_key'." 1
_arg_id="$2"
shift
;;
--id=*)
_arg_id="${_key##--id=}"
;;
-u*)
_arg_id="${_key##-u}"
;;
-N|--name)
test $# -lt 2 && die "Missing value for the optional argument '$_key'." 1
_arg_name="$2"
shift
;;
--name=*)
_arg_name="${_key##--name=}"
;;
-N*)
_arg_name="${_key##-N}"
;;
-E|--email)
test $# -lt 2 && die "Missing value for the optional argument '$_key'." 1
_arg_email="$2"
shift
;;
--email=*)
_arg_email="${_key##--email=}"
;;
-E*)
_arg_email="${_key##-E}"
;;
-C|--ciphertype)
test $# -lt 2 && die "Missing value for the optional argument '$_key'." 1
_arg_ciphertype="$2"
shift
;;
--ciphertype=*)
_arg_ciphertype="${_key##--ciphertype=}"
;;
-C*)
_arg_ciphertype="${_key##-C}"
;;
-L|--passlength)
test $# -lt 2 && die "Missing value for the optional argument '$_key'." 1
_arg_passlength="$2"
shift
;;
--passlength=*)
_arg_passlength="${_key##--passlength=}"
;;
-L*)
_arg_passlength="${_key##-L}"
;;
-X|--no-nosym|--nosym)
_arg_nosym="on"
test "${1:0:5}" = "--no-" && _arg_nosym="off"
;;
-X*)
_arg_nosym="on"
_next="${_key##-X}"
if test -n "$_next" -a "$_next" != "$_key"
then
{ begins_with_short_option "$_next" && shift && set -- "-X" "-${_next}" "$@"; } || die "The short option '$_key' can't be decomposed to ${_key:0:2} and -${_key:2}, because ${_key:0:2} doesn't accept value and '-${_key:2:1}' doesn't correspond to a short option."
fi
;;
-c|--exec)
test $# -lt 2 && die "Missing value for the optional argument '$_key'." 1
_arg_exec="$2"
shift
;;
--exec=*)
_arg_exec="${_key##--exec=}"
;;
-c*)
_arg_exec="${_key##-c}"
;;
-S|--storepath)
test $# -lt 2 && die "Missing value for the optional argument '$_key'." 1
_arg_storepath="$2"
shift
;;
--storepath=*)
_arg_storepath="${_key##--storepath=}"
;;
-S*)
_arg_storepath="${_key##-S}"
;;
-I|--no-identlink|--identlink)
_arg_identlink="on"
test "${1:0:5}" = "--no-" && _arg_identlink="off"
;;
-I*)
_arg_identlink="on"
_next="${_key##-I}"
if test -n "$_next" -a "$_next" != "$_key"
then
{ begins_with_short_option "$_next" && shift && set -- "-I" "-${_next}" "$@"; } || die "The short option '$_key' can't be decomposed to ${_key:0:2} and -${_key:2}, because ${_key:0:2} doesn't accept value and '-${_key:2:1}' doesn't correspond to a short option."
fi
;;
-h|--help)
print_help
exit 0
;;
-h*)
print_help
exit 0
;;
*)
_last_positional="$1"
_positionals+=("$_last_positional")
_positionals_count=$((_positionals_count + 1))
;;
esac
shift
done
}
assign_positional_args()
{
local _positional_name _shift_for=$1
_positional_names=""
_our_args=$((${#_positionals[@]} - 0))
for ((ii = 0; ii < _our_args; ii++))
do
_positional_names="$_positional_names _arg_verb[$((ii + 0))]"
done
shift "$_shift_for"
for _positional_name in ${_positional_names}
do
test $# -gt 0 || break
eval "$_positional_name=\${1}" || die "Error during argument parsing, possibly an Argbash bug." 1
shift
done
}
parse_commandline "$@"
assign_positional_args 1 "${_positionals[@]}"
# OTHER STUFF GENERATED BY Argbash
### END OF CODE GENERATED BY Argbash (sortof) ### ])
# [ <-- needed because of Argbash
for cmd in pass mktemp awk gpgconf tr wc head ; do
type "${cmd}" 2>/dev/null 1>&2 || { printf '%s required, please add to PATH\n' "${cmd}" 1>&2 ; exit 1 ; }
done
# so this is...typically a nice password extension.
# unless you invoked it outside pass, at which point
# it becomes a gpg *wrapper*!
case "${0}" in
*pass) : ;;
*)
# unfortunately, we need to grab the id and pull it over from gpg to us
gpgargs=("${@}")
# and to do _that_, we parse all the gpg options.
# option mess is from https://stackoverflow.com/questions/192249/how-do-i-parse-command-line-arguments-in-bash
! parser="$(getopt --options=bsau: --longoptions=status-fd: --name "$0" -- "$@")"
[[ "${PIPESTATUS[0]}" -ne 0 ]] && exit 2
eval set -- "$parser"
while true ; do
case "$1" in
-u)
lid="$2"
shift 2
break
;;
--)
shift
break
;;
# this quietly eats all the misunderstood args.
*)
shift
;;
esac
done
exec pass gpg passthrough -u "${lid}" "${gpgargs[@]}"
;;
esac
# tunable-ish-things
SEARCHPATHS=("${_arg_storepath}")
# just to help with stringies
D='-----'
exit_commands=()
cleanup () {
local function
[ "${exit_commands[0]}" ] || return 0
for function in "${exit_commands[@]}" ; do
${function}
done
}
trap cleanup EXIT ERR
# prepare to start a scratch GPG agent
old_gpg_home="${GNUPGHOME:-}"
unset GNUPGHOME
gpg_home="$(mktemp -d /dev/shm/scratch-gpg.XXXXXXXX)"
cleanup_gpg_home () {
[ "${gpg_home}" ] && rm -rf "${gpg_home}"
}
exit_commands=("${exit_commands[@]}" "cleanup_gpg_home")
export GNUPGHOME="${gpg_home}"
{
printf '%s\n' 'allow-loopback-pinentry'
} > "${GNUPGHOME}/gpg-agent.conf"
# handle calling pass...
pass () {
GNUPGHOME="${old_gpg_home}" command pass "${@}"
}
cleanup_gpg () {
[ "${gpg_home}" ] && gpgconf --kill gpg-agent
}
exit_commands=("cleanup_gpg" "${exit_commands[@]}")
start_gpg () {
gpg-agent --homedir "${GNUPGHOME}" --daemon
}
cleanup_pass_data () {
[ "${pass_data}" ] && rm -rf "${pass_data}"
}
exit_commands=("${exit_commands[@]}" "cleanup_pass_data")
pass_data="$(mktemp -d /dev/shm/pdata.XXXXXXXX)"
# try finding the key in SEARCHDIRS first, then try as a complete pass path.
pull_pass_item () {
local item="${1}"
local sdir
# walk searchpaths...
for sdir in "${SEARCHPATHS[@]}" ; do
pass ls "${sdir}/${item}" > "${pass_data}/item"
[[ -s "${pass_data}/item" ]] && return 0
done
# handle broader pass query
pass ls "${item}" > "${pass_data}/item"
[[ -s "${pass_data}/item" ]]
}
setup_and_exec () {
start_gpg
${_arg_exec}
}
# fatally exit the script if we fail this
err_req_item () {
[[ "${_arg_id}" ]] || { printf '%s\n' 'password item (-i --item) required' 1>&2 ; exit 1 ; }
}
err_req_name () {
[[ "${_arg_name}" ]] || { printf '%s\n' 'name (-N --name) required' 1>&2 ; exit 1 ; }
}
err_req_email () {
[[ "${_arg_email}" ]] || { printf '%s\n' 'email (-E --email) required' 1>&2 ; exit 1 ; }
}
# so... we copied over the password store item generation, because we don't
# want to commit the item until we have all the pieces together...
# CHARACTER_SET and CHARACTER_SET_NO_SYMBOLS are from pass (we're in a subshell...)
gpg_genkey_data () {
local ctype="${1}"
local pass
local characters="${CHARACTER_SET}"
[[ "${_arg_nosym}" == "on" ]] && characters="${CHARACTER_SET_NO_SYMBOLS}"
read -r -n "${_arg_passlength}" pass < <(LC_ALL=C tr -dc "${characters}" < /dev/urandom)
[[ "${#pass}" -eq "${_arg_passlength}" ]] || { printf '%s\n' 'could not generate password from /dev/urandom.' 1>&2 ; exit 1 ; }
echo "${pass}" > "${pass_data}/string"
case "${ctype}" in
ecc)
printf '%s: %s\n' \
Key-Type eddsa \
Key-Curve Ed25519 \
Key-Usage sign \
Subkey-Type ecdh \
Subkey-Curve Curve25519 \
Subkey-Usage encrypt
;;
rsa)
printf '%s: %s\n' \
Key-Type rsa \
Key-Length 4096 \
Key-Usage "encrypt sign"
;;
*)
printf '%s\n' 'only GPG key cipher settings for RSA [rsa] and ECC [ecc] are supported' 1>&2
exit 1
;;
esac
printf '%s: %s\n' \
Name-Real "${_arg_name}" \
Name-Email "${_arg_email}" \
Expire-Date 0 \
Preferences "SHA512 SHA384 SHA256 SHA224 AES256 AES192 AES CAST5 ZLIB BZIP2 ZIP Uncompressed" \
Passphrase "${pass}"
printf '%s\n' '%commit'
}
# GPG is from pass
gpg_print_fpr () {
local grip="${1}"
local line
while read -r line ; do
case "${line}" in
fpr*)
line="${line%:}"
line="${line##*:}"
echo "${line}"
return 0
;;
esac
done < <("${GPG}" --list-keys --with-colons --with-fingerprint "${grip}")
}
# GPG is from pass
# just...grab the first uid we find.
gpg_grab_uid () {
local line
local parts
while read -r line ; do
case "${line}" in
uid*)
IFS=: read -r -a parts <<<"${line}"
echo "${parts[9]}"
return 0
;;
esac
done < <("${GPG}" --list-keys --with-colons --with-fingerprint)
}
# GPG is from pass
gpg_export_keyset () {
{
cat "${pass_data}/string"
printf '%s%s%s\n' "${D}" 'BEGIN OWNER TRUST BLOCK' "${D}"
"${GPG}" --export-ownertrust
printf '%s%s%s\n' "${D}" 'END OWNER TRUST BLOCK' "${D}"
"${GPG}" --batch --passphrase-fd 0 --pinentry-mode loopback --armor --export-secret-key "${_arg_email}" < "${pass_data}/string"
"${GPG}" --batch --passphrase-fd 0 --pinentry-mode loopback --armor --export "${_arg_email}" < "${pass_data}/string"
local revocations
revocations=("${gpg_home}/openpgp-revocs.d/"*.rev)
[[ -f "${revocations[0]}" ]] && cat "${revocations[0]}"
} > "${pass_data}/item"
gpg_print_fpr "${_arg_email}"
}
gpg_import_keyset () {
local item="${1}"
local count
local uid
pull_pass_item "${item}"
head -n1 "${pass_data}/item" > "${pass_data}/string"
awk "/^${D}BEGIN OWNER TRUST BLOCK${D}/{f=1;next}/^${D}END OWNER TRUST BLOCK${D}/{f=0}f" < "${pass_data}/item" > "${pass_data}/ownertrust"
awk "/^${D}BEGIN PGP PRIVATE KEY BLOCK${D}/{f=1}/^${D}END PGP PRIVATE KEY BLOCK${D}/{print;f=0}f" < "${pass_data}/item" > "${pass_data}/private"
# we carefully step around the revocation certificate...
awk "/^${D}BEGIN PGP PUBLIC KEY BLOCK${D}/{f=1}/^${D}END PGP PUBLIC KEY BLOCK${D}/{if (f==1) {print;f=0};}f" < "${pass_data}/item" > "${pass_data}/public"
# we splice in the tail by couting most of the lines we've exported... (this is probably the revocation certificate)
count="$(wc -l "${pass_data}/ownertrust" "${pass_data}/private" "${pass_data}/public" | awk 'END { print $1}')"
# but we need to account for the ownertrust header/footer and password string.
count=$((count + 4))
tail "-n+${count}" "${pass_data}/item" > "${pass_data}/trailer"
# now that we have the *files* we can wire up gpg.
"${GPG}" --import "${pass_data}/public"
"${GPG}" --import-ownertrust "${pass_data}/ownertrust"
"${GPG}" --passphrase-file "${pass_data}/string" --pinentry-mode loopback --import "${pass_data}/private"
# finally, return the grip we imported
# we don't, uh, necessarily work with multiple keys here.
uid="$(gpg_grab_uid)"
gpg_print_fpr "${uid}"
}
# GPG is from pass
case "${_arg_verb[0]}" in
generate)
err_req_name
err_req_email
gpg_genkey_data "${_arg_ciphertype}" | "${GPG}" --gen-key --batch 2>/dev/null 1>&2
# gpg_export_keyset creates the pass item...
grip=$(gpg_export_keyset)
# we stomped on stdout here because the user doesn't get to _insert_ a password.
pass insert -m "${_arg_storepath}/${grip}" < "${pass_data}/item" 2>/dev/null 1>&2
# show the passdb modification in git though (assumes git-backing)
(cd "${PREFIX}/${_arg_storepath}" && git log -1 HEAD --oneline)
# this line *requires* the pass-ln extension from https://github.com/radian-software/pass-ln
pass ln "${_arg_storepath}/${grip}" "${_arg_storepath}/${_arg_email}"
;;
privkey)
err_req_item
uid="$(gpg_import_keyset "${_arg_id}")"
"${GPG}" --batch --passphrase-fd 0 --pinentry-mode loopback --armor --export-secret-key "${uid}" < "${pass_data}/string"
;;
pubkey)
err_req_item
uid="$(gpg_import_keyset "${_arg_id}")"
"${GPG}" --batch --passphrase-fd 0 --pinentry-mode loopback --armor --export "${uid}" < "${pass_data}/string"
;;
# this one shortcuts and just returns the first line of the item
phrase)
err_req_item
pull_pass_item "${_arg_id}"
head -n1 "${pass_data}/item"
;;
shell)
err_req_item
gpg_import_keyset "${_arg_id}"
setup_and_exec
;;
passthrough)
err_req_item
gpg_import_keyset "${_arg_id}"
# this removes the first verb - 'passthrough' and hands the rest to GPG
"${GPG}" --pinentry-mode loopback --passphrase-file "${pass_data}/string" "${_arg_verb[@]:1}"
;;
help|*)
print_help
;;
esac
# ] <-- needed because of Argbash
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment