#!/usr/bin/env bash # SPDX-License-Identifier: GPL-2.0-or-later # Copyright (C) 2021 Ayush Agarwal # # vim: set expandtab ts=2 sw=2 sts=2: # # tessen - a data selection interface for pass on Wayland # ------------------------------------------------------------------------------ # don't leak password data if debug mode is enabled set +x # GLOBAL VARIABLES readonly tsn_version="2.0.0" declare pass_backend dmenu_backend tsn_action tsn_config declare -a dmenu_backend_opts tmp_opts declare tsn_userkey tsn_urlkey tsn_autokey tsn_delay tsn_web_browser # show both actions, 'autotype' and 'copy', to choose from by default tsn_action="default" tsn_otp=false # initialize default values for keys tsn_userkey="user" tsn_urlkey="url" tsn_autokey="autotype" tsn_delay=100 # initialize the default location of the config file tsn_config="${XDG_CONFIG_HOME:-$HOME/.config}"/tessen/config # variables with sensitive data which will be manually unset using _clear declare tsn_passfile tsn_username tsn_password tsn_url tsn_autotype chosen_key declare -A tsn_passdata # FIRST MENU: generate a list of pass files, let the user select one get_pass_files() { local tmp_prefix="${PASSWORD_STORE_DIR:-$HOME/.password-store}" if ! [[ -d "$tmp_prefix" ]]; then _die "password store directory not found" fi local -a tmp_pass_files # temporarily enable globbing, get the list of all gpg files recursively, # remove PASSWORD_STORE_DIR from the file names, and remove the '.gpg' suffix shopt -s nullglob globstar tmp_pass_files=("$tmp_prefix"/**/*.gpg) tmp_pass_files=("${tmp_pass_files[@]#"$tmp_prefix"/}") tmp_pass_files=("${tmp_pass_files[@]%.gpg}") shopt -u nullglob globstar tsn_passfile="$(printf "%s\n" "${tmp_pass_files[@]}" \ | "$dmenu_backend" "${dmenu_backend_opts[@]}")" if ! [[ -s "$tmp_prefix/$tsn_passfile".gpg ]]; then _die fi unset -v tmp_pass_files tmp_prefix } # FIRST MENU: generate a list of gopass files, let the user select one # this function feels like a hack to me. ideally, the issues that led to this # hack should be fixed in gopass but if anyone has any suggestions about making # this function better, please raise a PR get_gopass_files() { local line path_files file mount_name tmp_tsn_passfile local -A tmp_gopass_files local -a mount_name_arr # this feels like a hack and it's dependent on the output of `gopass config` # # still, this block of code saves us from using coreutils # # to be clear, this is needed to confirm whether the filename entered in the # dmenu actually exists or not because dmenu backends will happily print the # input received from a user even if that input doesn't exist in the menu # presented to the user # # if you're wondering why I didn't just use `gopass ls -f`, it's because in # an apparent effort to be user-friendly, `gopass show -n invalid-input` # doesn't seem to exit with an error # https://github.com/gopasspw/gopass/issues/551 # like drew devault wrote on his blog, I hate the stale bot # https://drewdevault.com/2021/10/26/stalebot.html shopt -s nullglob globstar while read -r line || [[ -n "$line" ]]; do # we could've used `gopass config path` but since we have parse the output # of `gopass config` because of possible mounts, better to just use `gopass # config` # we assume that we'll encounter `path: ...` only once and as soon as we # do, we parse the list of all the files inside the dir and store them in # an associative array with the name of the files as the index and the path # as the value if [[ "$line" == path* ]] && [[ -d "${line#* }" ]]; then path_files=("${line#* }"/**/*.gpg) path_files=("${path_files[@]#"${line#* }"/}") path_files=("${path_files[@]%.gpg}") for file in "${path_files[@]}"; do tmp_gopass_files["$file"]="${line#* }" done fi # similarly, we go through the mount points, generate the list of files # inside those mount points, add those files to the associative array with # the file names as the index and the location of the mount point as the # value # # there's no easy way to parse and associate file names with mount points # so we'll have to resort to some ugly hacks again if [[ "$line" == mount* ]]; then # remove the quotes from the parsed line line="${line//\"/}" # the mount name needs to be extracted to distinguish files with # potentially identical names mount_name="${line#mount *}" mount_name="${mount_name% =>*}" mount_name_arr+=("$mount_name") if [[ -d "${line#*=> }" ]]; then path_files=("${line#*=> }"/**/*.gpg) path_files=("${path_files[@]#"${line#*=> }"/}") path_files=("$mount_name"/"${path_files[@]%.gpg}") for file in "${path_files[@]}"; do tmp_gopass_files["$file"]="${line#*=> }" done fi fi done < <(gopass config) shopt -u nullglob globstar # the actual menu tsn_passfile="$(printf "%s\n" "${!tmp_gopass_files[@]}" \ | "$dmenu_backend" "${dmenu_backend_opts[*]}")" if [[ -z "$tsn_passfile" ]]; then _die fi # remove the mount name for the path check to be successful # initialize the temp variable with the value of tsn_passfile in case an # entry from the gopass path is chosen tmp_tsn_passfile="$tsn_passfile" for idx in "${mount_name_arr[@]}"; do if [[ "${tsn_passfile%%/*}" == "$idx" ]]; then tmp_tsn_passfile="${tsn_passfile#*/}" fi done # we had to use an associative array to keep track of the absolute path of # the selected file because it is possible to give invalid input to dmenu # while making a selection and tessen should exit in that case if [[ -n "${tmp_gopass_files["$tsn_passfile"]}" ]]; then if ! [[ -f "${tmp_gopass_files["$tsn_passfile"]}"/"$tmp_tsn_passfile".gpg ]]; then _die "the selected file was not found" fi fi unset -v tmp_gopass_files line path_files file mount_name mount_name_arr tmp_tsn_passfile } # parse the password store file for username, password, otp, custom autotype, # and other key value pairs get_pass_data() { local -a passdata local keyval_regex otp_regex idx key val mapfile -t passdata < <(pass "$tsn_passfile" 2> /dev/null) if [[ "${#passdata[@]}" -eq 0 ]]; then _die "$tsn_passfile is empty" fi # the key can contain # alphanumerics, spaces, hyphen, underscore, plus, at, and hash # the value can contain # anything but it should be separated with a space from 'key:' keyval_regex='^[[:alnum:][:blank:]+#@_-]+:[[:blank:]].+$' # parse the 'otpauth://' URI # this regex is borrowed from pass-otp at commit 0aadd4c otp_regex='^otpauth:\/\/(totp|hotp)(\/(([^:?]+)?(:([^:?]*))?))?\?(.+)$' # the first line should contain the only the password tsn_password="${passdata[0]}" # each key should be unique # if non-unique keys are present, the value of the last non-unique key will # be considered # in addition, the following keys should be case insensitive and unique # 'username', 'autotype' for idx in "${passdata[@]:1}"; do key="${idx%%:*}" val="${idx#*: }" # keys with the case insensitive name 'password' are ignored if [[ "${key,,}" == "password" ]]; then continue elif [[ -z "${tsn_username}" ]] && [[ "${key,,}" =~ ^${tsn_userkey_regex}$ ]]; then tsn_username="$val" tsn_userkey="${key,,}" elif [[ "${key,,}" == "$tsn_autokey" ]]; then tsn_autotype="$val" elif [[ "$idx" =~ $otp_regex ]]; then tsn_otp=true elif [[ "$idx" =~ $keyval_regex ]]; then tsn_passdata["$key"]="$val" fi done # if $tsn_userkey_regex isn't found, use the basename of file as username if [[ -z "$tsn_username" ]]; then tsn_username="${tsn_passfile##*/}" fi } # SECOND MENU: show a list of possible keys to choose from for auto typing or # copying # THIRD MENU: optional, this will show up if TESSEN_ACTION is blank get_key() { local -a key_arr local ch flag=false # the second menu if [[ "$1" == "key_list" ]]; then if [[ "$tsn_otp" == "true" ]]; then key_arr=("$tsn_autokey" "$tsn_userkey" "password" "otp" "${!tsn_passdata[@]}") else key_arr=("$tsn_autokey" "$tsn_userkey" "password" "${!tsn_passdata[@]}") fi # the (optional) third menu, depends on $tsn_action elif [[ "$1" == "option" ]]; then key_arr=("$tsn_autokey" "copy") elif [[ "$1" == "$tsn_urlkey" ]]; then key_arr=("open" "copy") fi # a dynamically scoped variable to hold the selected key for key_menu chosen_key="$(printf "%s\n" "${key_arr[@]}" | "$tsn_backend" "${tsn_backend_opts[@]}")" # validate the chosen key, if it doesn't exist, exit for ch in "${key_arr[@]}"; do if [[ "$chosen_key" == "$ch" ]]; then flag=true break fi done if [[ "$flag" == "false" ]]; then _die fi } # SECOND MENU: use 'get_key()' to show a list of possible keys to choose from key_menu() { get_key key_list case "$chosen_key" in "$tsn_autokey") auto_type_def ;; "$tsn_userkey") key_action "$tsn_username" ;; password) key_action "$tsn_password" ;; otp) key_otp ;; "$tsn_urlkey") key_url "${tsn_passdata["$tsn_urlkey"]}" ;; *) key_action "${tsn_passdata["$chosen_key"]}" ;; esac } # THIRD MENU: optional, use 'get_key()' and TESSEN_ACTION to show the option to # either auto type or copy the selected key key_action() { local arg="$1" # POTENTIAL IMPROVEMENT: used 'printf | wtype' instead of 'auto_type()' # because in all the other cases, 'auto_type()' is meant to exit but we don't # want to exit here case "$tsn_action" in autotype) auto_type "$arg" ;; copy) wld_copy "$arg" ;; both) printf "%s" "$arg" | wtype -s "$tsn_delay" - wld_copy "$arg" ;; "") get_key option if [[ "$chosen_key" == "$tsn_autokey" ]]; then auto_type "$arg" else wld_copy "$arg" fi ;; esac } # THIRD MENU: optional, this function is used if an 'otpauth://' URI is found key_otp() { local tmp_otp if ! pass otp -h > /dev/null 2>&1; then _die "pass-otp is not installed" fi tmp_otp="$(pass otp "$tsn_passfile")" if [[ "$tmp_otp" =~ ^[[:digit:]]+$ ]]; then get_key option if [[ "$chosen_key" == "$tsn_autokey" ]]; then auto_type "$tmp_otp" else wld_copy "$tmp_otp" fi else _die "invalid OTP detected" fi } # THIRD MENU: optional, this function is used if TESSEN_URLKEY is found. # Instead of showing 'autotype', it will show 'open'. # This function could've been combined with 'key_action()' but it would've # become a bit more complex than I like. key_url() { local arg="$1" case "$tsn_action" in autotype) key_open_url "$arg" || _die _clear ;; copy) wld_copy "$arg" ;; both) key_open_url "$arg" wld_copy "$arg" ;; "") get_key "$tsn_urlkey" if [[ "$chosen_key" == "open" ]]; then key_open_url "$arg" || _die _clear else wld_copy "$arg" fi ;; esac } # use either xdg-open or $BROWSER to open the selected URL key_open_url() { if is_installed xdg-open; then xdg-open "$1" 2> /dev/null || { printf "%s\n" "xdg-open was unable to open '$1'" >&2 return 1 } elif [[ -n "$BROWSER" ]] && is_installed "$BROWSER"; then "$BROWSER" "$1" > /dev/null 2>&1 || { printf "%s\n" "$BROWSER was unable to open '$1'" >&2 return 1 } else _die "failed to open '$tsn_urlkey'" fi } # SECOND MENU: the default autotype function, either autotype the username and # password or the custom autotype defined by the user # POTENTIAL IMPROVEMENT: Anything better than this ugly hack of # else..for..case..if..else..if? auto_type_def() { local word tmp_otp if [[ -z "$tsn_autotype" ]]; then printf "%s" "$tsn_username" | wtype -s "$tsn_delay" - wtype -s "$tsn_delay" -k Tab -- printf "%s" "$tsn_password" | wtype -s "$tsn_delay" - else for word in $tsn_autotype; do case "$word" in ":delay") sleep 1 ;; ":tab") wtype -s "$tsn_delay" -k Tab -- ;; ":space") wtype -s "$tsn_delay" -k space -- ;; ":enter") wtype -s "$tsn_delay" -k Return -- ;; ":otp") if ! pass otp -h > /dev/null 2>&1; then _die "pass-otp is not installed" else tmp_otp="$(pass otp "$tsn_passfile")" if [[ "$tmp_otp" =~ ^[[:digit:]]+$ ]]; then printf "%s" "$tmp_otp" | wtype -s "$tsn_delay" - else _die "invalid OTP detected" fi fi ;; path | basename | filename) printf "%s" "${tsn_passfile##*/}" | wtype -s "$tsn_delay" - ;; "$tsn_userkey") printf "%s" "$tsn_username" | wtype -s "$tsn_delay" - ;; pass | password) printf "%s" "$tsn_password" | wtype -s "$tsn_delay" - ;; *) if [[ -n "${tsn_passdata["$word"]}" ]]; then printf "%s" "${tsn_passdata["$word"]}" | wtype -s "$tsn_delay" - else wtype -s "$tsn_delay" -k space -- fi ;; esac done fi _clear exit 0 } auto_type() { printf "%s" "$1" | wtype -s "$tsn_delay" - _clear exit 0 } # POTENTIAL IMPROVEMENT: We could restore the clipboard as it was before pass # was used. This is done by default by pass. wld_copy() { printf "%s" "$1" | wl-copy if is_installed notify-send; then notify-send -t $((tsn_cliptime * 1000)) "Copied username to clipboard. Will clear in $tsn_cliptime seconds." fi { sleep "$tsn_cliptime" || exit 1 wl-copy --clear } > /dev/null 2>&1 & unset -v tsn_passfile tsn_username tsn_password tsn_passdata chosen_key } print_help() { printf "%s" "\ ${0##*/} - autotype and copy data from password-store on wayland usage: ${0##*/} [options] ${0##*/} use a dmenu backend and either autotype OR copy data ${0##*/} -b bemenu use bemenu and either autotype OR copy data ${0##*/} -b 'bemenu -l 20' use bemenu but override default options and show 20 lines ${0##*/} -b bemenu -a autotype use bemenu and always autotype data ${0##*/} -b bemenu -a copy use bemenu and always copy data ${0##*/} -b bemenu -a both use bemenu and always autotype AND copy data -b, --backend, --backend= specify a dmenu like backend and (optionally) its flags -a, --action, --action= choose either 'autotype', 'copy', or 'both' -h, --help print this help menu -v, --version print the version of ${0##*/} for more details and additional features, please read the man page of tessen(1) for reporting bugs or feedback, visit https://github.com/ayushnix/tessen " } is_installed() { if command -v "${1%% *}" > /dev/null 2>&1; then return 0 else return 1 fi } setup_backend() { local backend="$1" local backend_opts="${backend#* }" backend="${backend%% *}" if ! is_installed "$backend"; then _die "'$backend' is not installed" fi if [[ "$backend_opts" == "$backend" ]]; then backend_opts="$(get_default_opts "$backend")" if [[ -z "$backend_opts" ]]; then printf "%s\n" "unable to determine any dmenu options for '$backend'" >&2 fi fi readonly tsn_backend="$backend" if [[ -n "$backend_opts" ]]; then mapfile -t -d ' ' backend_opts < <(printf "%s" "$backend_opts") readonly -a tsn_backend_opts=("${backend_opts[@]}") else readonly -a tsn_backend_opts=() fi } get_default_opts() { case "$1" in bemenu) printf "%s" "-i -l 10 -w --scrollbar=autohide -n" return 0 ;; rofi) printf "%s" "-dmenu" return 0 ;; wofi | fuzzel) printf "%s" "-d" return 0 ;; *) printf "" return 1 ;; esac } find_backend() { local dmbd for dmbd in "${tsn_known_backends[@]}"; do if is_installed "$dmbd"; then printf "%s" "$dmbd" return 0 fi done _die "%s\n" "unable to find a 'dmenu' compatible application" } validate_cliptime() { local clip_regex clip_regex="^[[:digit:]]+$" if [[ "$tsn_cliptime" =~ $clip_regex ]]; then return 0 else _die "Invalid clipboard time provided" fi } validate_action() { case "$1" in autotype) readonly tsn_action="autotype" ;; copy) readonly tsn_action="copy" ;; both) readonly tsn_action="both" ;; "") readonly tsn_action="" ;; *) _die "please specify a valid action: autotype | copy | both" ;; esac } _clear() { wl-copy --clear unset -v tsn_passfile tsn_username tsn_password tsn_passdata chosen_key } _die() { if [[ -n "$1" ]]; then printf "%s\n" "$1" >&2 fi _clear exit 1 } main() { local _opt if ! [[ -d "$tsn_prefix" ]]; then _die "password store directory not found" fi while [[ "$#" -gt 0 ]]; do _opt="$1" case "$_opt" in -b | --backend) tsn_backend="${2}" shift ;; --backend=*) tsn_backend="${_opt##--backend=}" ;; -a | --action) if [[ "$#" -lt 2 ]]; then _die "please specify a valid action: autotype | copy | both" fi validate_action "$2" shift ;; --action=*) validate_action "${_opt##--action=}" ;; -h | --help) print_help exit 0 ;; -v | --version) printf "%s\n" "${0##*/} version $tsn_version" exit 0 ;; --) shift break ;; *) _die "invalid argument detected" ;; esac shift done unset -v _opt if [[ -z "${tsn_backend}" ]]; then tsn_backend="$(find_backend)" fi setup_backend "${tsn_backend}" validate_cliptime readonly tsn_action trap '_clear' EXIT TERM get_pass_file get_pass_data key_menu trap - EXIT TERM } main "$@"