Created
November 22, 2023 16:43
-
-
Save arrjay/a0452a805e113350daa85991fd8976d9 to your computer and use it in GitHub Desktop.
pass extension for gpg shenanigans
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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