#!/bin/bash

# Copyright © 2025 Jean-Jacques Brucker (u4sRyUhEbNU5OwyLEjfSwaXAe_42.17-002.76) <jjbrucker@foopgp.org>
# Copyright © 2026 Mnêmê (u5001777236237.945e_43.30_005.38) <mneme@foopgp.org>
# SPDX-License-Identifier: GPL-3.0-only

helpmsg="Usage: ${0##*/} [OPTIONS] [SSH_OPTIONS] destination [SSH_OPTIONS] [command [argument ...]]

SSH With GnuPG: ssh wrapper that dynamically forwards
the gpg-agent (gpg + ssh sockets).

This wrapper makes first an extra ssh connection to get server's gpg-agent
socket paths, then opens the real connection with two RemoteForward entries:
  - agent-socket       ← client's agent-extra-socket (restricted, sandbox)
  - agent-ssh-socket   ← client's agent-ssh-socket   (ssh-auth via OpenPGP)

These socket forwarding let the server use the client's OpenPGP keys
(typically on a smartcard/YubiKey/NitroKey) — enabling multi-hop chains
between machines without ever copying private keys.

SSH_AUTH_SOCK is set to gpg-agent's ssh socket for the connections this
wrapper makes, whatever agent it named before: nothing has to be arranged in
a login profile beforehand. gpg-agent still needs 'enable-ssh-support' in its
own configuration; this says so when the socket is missing, and --install
writes it.

Or every program, without this wrapper and without the forwarding -- ssh,
scp, and git both to reach a server and to sign a commit, then ask gpg-agent
for the key:

  sshwgpg --install

SSH_OPTIONS passed AFTER the destination (server) are used only by the real
ssh connection, while SSH_OPTIONS passed BEFORE the destination are used by
both ssh connections ‒ the first one and the real one.

Options:
      --no-gpg             do not forward the gpg-agent socket (no remote
                           sign/decrypt — useful when the remote is only
                           used as an ssh bastion).
      --no-ssh             do not forward the ssh-agent socket (no outbound
                           ssh from the remote — minimizes exposure when
                           only remote sign/decrypt is needed). With
                           --no-gpg, nothing is forwarded and no extra
                           connection is made: what is left is ssh
                           authenticating with gpg-agent's key.
      --check-conf         check what this needs, here and on the
                           destination, and say what to do about whatever is
                           missing. Changes nothing, and exits. Without a
                           destination, this machine alone is checked and no
                           connection is made.
      --install            make gpg-agent the ssh agent of every shell: a
                           block between two markers in ~/.bashrc, running
                           --ssh-agent-env, and 'enable-ssh-support' in
                           gpg-agent.conf when the agent has no ssh socket.
                           Run again, it rewrites the block. Takes effect in
                           new shells.
      --uninstall          remove that block; gpg-agent.conf is left as it is.
      --ssh-agent-env      print the line that makes gpg-agent the ssh agent
                           -- SSH_AUTH_SOCK, which ssh, ssh-add and git's
                           signing all read -- starting the agent if it is
                           not running:
                             eval \"\$(sshwgpg --ssh-agent-env)\"
      --dry-run            don't execute ssh, just show what would be done.
      --help               show this help and exit
      --version            show versions and exit

See ssh(1) for SSH_OPTIONS, and sshwgpg(1) for server configuration and examples.
"

set -euo pipefail

version="1.5.0"
args=()
dest=""
dryrun=""
fwd_gpg=1
fwd_ssh=1
check_conf=0
action=""

# What --install writes to.
bashrc="$HOME/.bashrc"

# gpg-agent's ssh socket, by its own account. The documented spelling is the
# plural one; older scripts here used the abbreviation.
gpg_socket() {
	gpgconf --list-dirs "$1" 2>/dev/null
}

# usage: ssh [-46AaCfGgKkMNnqsTtVvXxYy] [-B bind_interface] [-b bind_address]
#            [-c cipher_spec] [-D [bind_address:]port] [-E log_file]
#            [-e escape_char] [-F configfile] [-I pkcs11] [-i identity_file]
#            [-J destination] [-L address] [-l login_name] [-m mac_spec]
#            [-O ctl_cmd] [-o option] [-P tag] [-p port] [-R address]
#            [-S ctl_path] [-W host:port] [-w local_tun[:remote_tun]]
#            destination [command [argument ...]]
#        ssh [-Q query_option]
# Search for destination
for ((;$#;)) ; do
	if [[ "$1" =~ ^-[BbcDEeFIiJLlmOoPpRSWw]$ ]] ; then
		# Option has a value
		args+=("$1")
		shift
		args+=("$1")
	elif [[ "$1" =~ ^-[46AaCfGgKkMNnqsTtVvXxYy]$ ]] ; then
		# Option has no value
		args+=("$1")
	elif [[ "$1" =~ ^-Q$ ]] ; then
		# local query
		printf "%s: Error: For ssh queries (-Q), please use ssh directly.\n" "${0##*/}" >&2
		exit 1
	elif [[ "$1" =~ ^--$ ]] ; then
		# This marks the end of options, next arg is the destination (which could be start with a '-' (insane !))
		args+=("$1")
		shift
		dest="$1"
		break
	elif [[ "$1" =~ ^--help$ ]] ; then
		echo "$helpmsg"
		exit 0
	elif [[ "$1" =~ ^--version$ ]] ; then
		echo "${0##*/} $version"
		ssh -V
		exit 0
	elif [[ "$1" =~ ^--dry-run$ ]] ; then
		dryrun="echo"
	elif [[ "$1" =~ ^--no-gpg$ ]] ; then
		fwd_gpg=0
	elif [[ "$1" =~ ^--no-ssh$ ]] ; then
		fwd_ssh=0
	elif [[ "$1" =~ ^--check-conf$ ]] ; then
		check_conf=1
	elif [[ "$1" =~ ^--install$ ]] ; then
		action=install
	elif [[ "$1" =~ ^--uninstall$ ]] ; then
		action=uninstall
	elif [[ "$1" =~ ^--ssh-agent-env$ ]] ; then
		action=ssh-agent-env
	elif [[ "$1" =~ ^- ]] ; then
		printf "%s: Warning: assuming no value is following unknown option -- %s\n" "${0##*/}" "$1" >&2
		args+=("$1")
	else
		dest="$1"
        break
	fi
	shift
done


# -- Checking, rather than guessing --------------------------------------------
#
# What breaks a connection here is a configuration missing at one end or the
# other, and a wrapper that *guesses* at the cause helps nobody: the one guess
# this script used to make was wrong, and the real cause that day was a server
# whose keyring did not hold the certificate (JJB, 2026-09-24). So these check
# the **state** rather than the wording of a configuration file -- a socket
# that exists proves enable-ssh-support better than any grep, and rebinding a
# path twice proves StreamLocalBindUnlink -- and the file is named only to say
# where to write the fix.
#
# Nothing here changes anything. Each failure prints a line to paste.

checked_sock=""

check_ok() { printf "%s: ok    %s\n" "${0##*/}" "$1" ; }
check_no() { printf "%s: TODO  %s\n" "${0##*/}" "$1" ; found=1 ; }
check_fix() { printf "              %s\n" "$1" ; }

# This machine: an agent that answers, holding at least one key.
check_local_conf() {
	local sock keys
	sock=$(gpg_socket agent-ssh-socket)
	if [[ -z "$sock" ]] ; then
		check_no "gpgconf does not say where gpg-agent's ssh socket is"
		check_fix "apt install gnupg"
		return
	fi
	[[ -S "$sock" ]] || gpgconf --launch gpg-agent >/dev/null 2>&1 || true
	if [[ -S "$sock" ]] ; then
		check_ok "gpg-agent answers ssh on $sock"
		checked_sock="$sock"
	else
		check_no "gpg-agent has no ssh socket (enable-ssh-support is off)"
		check_fix "printf 'enable-ssh-support\\n' >> \"\$(gpgconf --list-dirs homedir)/gpg-agent.conf\" && gpgconf --kill gpg-agent"
		return
	fi
	keys=$(SSH_AUTH_SOCK="$sock" ssh-add -L 2>/dev/null | grep -c '^ssh-' || true)
	if ((keys > 0)) ; then
		check_ok "it serves $keys key(s)"
	else
		check_no "it serves no key: the card is absent, or gpg has never seen it"
		check_fix "gpg --card-status"
	fi
}

# Which certificate the key we authenticate with belongs to.
#
# Matched rather than guessed: each local secret key is exported as an ssh key
# and compared with what the agent actually serves. The comment an agent puts
# on its line says "cardno:…" and not which certificate it is, so reading it
# would be guessing.
local_certificate() {
	local served fpr mine
	served=$(SSH_AUTH_SOCK="$1" ssh-add -L 2>/dev/null | awk '/^ssh-/ {print $2}')
	[[ "$served" ]] || return 1
	while read -r fpr ; do
		[[ "$fpr" ]] || continue
		mine=$(gpg --export-ssh-key "$fpr" 2>/dev/null | awk '{print $2}')
		[[ "$mine" ]] || continue
		if grep -qxF "$mine" <<< "$served" ; then
			printf "%s\n" "$fpr"
			return 0
		fi
	done < <(gpg --list-secret-keys --with-colons 2>/dev/null |
		awk -F: '$1=="sec"{want=1} $1=="fpr" && want {print $10; want=0}')
	return 1
}

# The destination: somewhere to forward to, our certificate in its keyring,
# and a server that takes a socket back after the connection that made it has
# gone.
check_remote_conf() {
	local probe
	if ssh "${args[@]}" -- "$dest" "command -v gpgconf >/dev/null 2>&1" ; then
		check_ok "$dest has gpgconf (its gpg sockets can be forwarded)"
	else
		check_no "$dest has no gpgconf: nothing to forward for gpg"
		check_fix "sshwgpg --no-gpg $dest        # or install gnupg over there"
	fi
	# The certificate, which is what JJB spent an evening on: with the agent
	# forwarded, gpg on the far side still needs the *public* key to know
	# which key to ask the agent for. Without it, signing there fails for a
	# reason that looks like anything but a missing certificate
	# (JJB, 2026-09-24).
	cert=$(local_certificate "$checked_sock") || cert=""
	if [[ -z "$cert" ]] ; then
		check_no "no certificate here matches the key the agent serves"
		check_fix "gpg --card-status        # and check the card is the one you think"
	elif ssh "${args[@]}" -- "$dest" "gpg --list-keys '$cert' >/dev/null 2>&1" ; then
		check_ok "$dest holds your certificate ${cert:0:16}…"
	else
		check_no "$dest has not got your certificate: gpg there cannot sign or decrypt with the forwarded agent"
		check_fix "gpg --export $cert | ssh $dest 'gpg --import'"
	fi
	# Bound twice on purpose. The local end is a path that does not exist:
	# what is being tested is the server's willingness to rebind, and our own
	# agent has no business being reachable during a check.
	probe="/tmp/sshwgpg-check-$$.sock"
	if ! ssh -o ExitOnForwardFailure=yes -o "RemoteForward $probe /nonexistent/sshwgpg.sock" \
		"${args[@]}" -- "$dest" true 2>/dev/null ; then
		check_no "$dest refused to bind $probe at all"
		check_fix "check that /tmp is writable there, and that AllowStreamLocalForwarding is not 'no'"
		return
	fi
	if ssh -o ExitOnForwardFailure=yes -o "RemoteForward $probe /nonexistent/sshwgpg.sock" \
		"${args[@]}" -- "$dest" true 2>/dev/null ; then
		check_ok "$dest takes a forwarded socket back (StreamLocalBindUnlink yes)"
	else
		check_no "$dest leaves a stale socket behind: the second connection cannot rebind"
		check_fix "ssh $dest \"printf 'StreamLocalBindUnlink yes\\n' | sudo tee /etc/ssh/sshd_config.d/80-StreamLocal.conf && sudo systemctl reload ssh\""
	fi
	ssh "${args[@]}" -- "$dest" "rm -f '$probe'" >/dev/null 2>&1 || true
}

# -- Every program, without the wrapper -----------------------------------------
#
# What sshwfoodjis does on a phone, done here for gpg-agent: one block in
# ~/.bashrc, and every program that reads SSH_AUTH_SOCK -- ssh, scp, ssh-add,
# and ssh-keygen, which git runs to sign a commit -- asks gpg-agent for the
# key (JJB, 2026-10-05). The forwarding stays the wrapper's: a plain ssh
# forwards nothing.

# gpg-agent's ssh socket, the agent started if it has never run: an agent
# that has never run has no socket yet, and starting it is what any gpg
# command would have done anyway. Fails, saying why, when there is none.
local_agent_socket() {
	local sock
	sock=$(gpg_socket agent-ssh-socket)
	if [[ -z "$sock" ]] ; then
		printf "%s: Error: gpgconf does not say where gpg-agent's ssh socket is.\n" "${0##*/}" >&2
		return 1
	fi
	[[ -S "$sock" ]] || gpgconf --launch gpg-agent >/dev/null 2>&1 || true
	if [[ ! -S "$sock" ]] ; then
		printf "%s: Error: %s does not exist. Add 'enable-ssh-support' to gpg-agent.conf (sshwgpg --install does) and try again.\n" \
			"${0##*/}" "$sock" >&2
		return 1
	fi
	printf "%s\n" "$sock"
}

ssh_agent_env() {
	local sock
	sock=$(local_agent_socket) || return 1
	printf "export SSH_AUTH_SOCK=%q\n" "$sock"
}

# Whether --install has made every shell's agent gpg-agent.
installed() {
	grep --quiet '^# BEGIN sshwgpg$' "$bashrc" 2>/dev/null
}

# A block between markers, and the lines around it left as they were.
remove_block() {
	grep --quiet '^# BEGIN sshwgpg$' "$1" 2>/dev/null || return 1
	sed --in-place '/^# BEGIN sshwgpg$/,/^# END sshwgpg$/d' "$1"
}

# The state, not the wording: an agent that has a ssh socket has the option,
# wherever it was written. Only when it has none is the line added, and the
# agent started again for it to count -- which forgets the passphrases and
# PINs it was keeping, so it is said.
enable_ssh_support() {
	local sock conf
	sock=$(gpg_socket agent-ssh-socket)
	if [[ -z "$sock" ]] ; then
		printf "%s: Error: gpgconf does not say where gpg-agent's ssh socket is.\n" "${0##*/}" >&2
		return 1
	fi
	[[ -S "$sock" ]] || gpgconf --launch gpg-agent >/dev/null 2>&1 || true
	[[ ! -S "$sock" ]] || return 0
	conf="$(gpg_socket homedir)/gpg-agent.conf"
	mkdir --parents --mode 700 "${conf%/*}"
	printf "enable-ssh-support\n" >>"$conf"
	gpgconf --kill gpg-agent >/dev/null 2>&1 || true
	gpgconf --launch gpg-agent >/dev/null 2>&1 || true
	printf "%s: 'enable-ssh-support' added to %s, and gpg-agent started again.\n" "${0##*/}" "$conf" >&2
	if [[ ! -S "$sock" ]] ; then
		printf "%s: Error: %s still does not exist.\n" "${0##*/}" "$sock" >&2
		return 1
	fi
}

install_conf() {
	local self
	self="$(readlink -f -- "$0")"
	remove_block "$bashrc" || true
	{
		printf "# BEGIN sshwgpg\n"
		printf "# Written on %s by sshwgpg %s --install;\n" "$(date --iso-8601=seconds)" "$version"
		printf "# removed by sshwgpg --uninstall. gpg-agent as the SSH agent of this\n"
		printf "# shell: ssh, scp, ssh-add, and git signing commits with an SSH key.\n"
		printf "if [ -x %q ] ; then eval \"\$(%q --ssh-agent-env 2>/dev/null)\" ; fi\n" "$self" "$self"
		printf "# END sshwgpg\n"
	} >>"$bashrc"
}

case "$action" in
	# Said on stderr: what a package's maintainer script lets through.
	install)
		enable_ssh_support || exit 1
		if installed ; then
			install_conf
			printf "%s: reinstalled in %s: new shells ask gpg-agent for the key.\n" "${0##*/}" "$bashrc" >&2
		else
			install_conf
			printf "%s: new shells ask gpg-agent for the key -- ssh, scp, git (%s).\n" "${0##*/}" "$bashrc" >&2
		fi
		exit 0 ;;
	uninstall)
		if remove_block "$bashrc" ; then
			printf "%s: removed from %s.\n" "${0##*/}" "$bashrc" >&2
		else
			printf "%s: nothing to remove from %s.\n" "${0##*/}" "$bashrc" >&2
		fi
		exit 0 ;;
	ssh-agent-env)
		ssh_agent_env || exit 1
		exit 0 ;;
esac

if ((check_conf)) ; then
	found=0
	check_local_conf
	[[ -z "$dest" ]] || check_remote_conf
	((found)) || printf "%s: nothing to do.\n" "${0##*/}"
	exit $((found))
fi

if [[ -z "$dest" ]] ; then
	printf "%s: Error: no destination.\n\n%s" "${0##*/}" "$helpmsg" >&2
	exit 1
fi

# Which agent the local ssh uses, and which one gets forwarded: gpg-agent's,
# whatever SSH_AUTH_SOCK named before.
#
# Nothing on a plain Debian points SSH_AUTH_SOCK at gpg-agent: a login profile
# has to, and that snippet is exactly the kind of patch under /etc we would
# rather stop shipping (djibian-gpgconfig's gpg4ssh.sh, JJB, 2026-09-24). A
# tool called "ssh with GnuPG" can name the socket itself.
local_ssh_socket=$(local_agent_socket) || exit 1
export SSH_AUTH_SOCK="$local_ssh_socket"

# Build the list of `-o RemoteForward …` options to pass to the final ssh.
# Only query the remote for the paths we actually need; skip the preparatory
# connection entirely if both forwardings have been disabled.
ssh_opts=()

if ((fwd_gpg || fwd_ssh)) ; then
	probe_cmd=""
	((fwd_gpg)) && probe_cmd+="gpgconf --list-dirs agent-socket ; "
	((fwd_ssh)) && probe_cmd+="gpgconf --list-dirs agent-ssh-socket ; "
	remote_paths=$(ssh "${args[@]}" -- "$dest" "$probe_cmd") || {
		printf "%s: Error: preparatory ssh to %s failed.\n" "${0##*/}" "$dest" >&2
		exit 1
	}
	if ((fwd_gpg && fwd_ssh)) ; then
		remote_gpg_socket=${remote_paths%$'\n'*}
		remote_ssh_socket=${remote_paths#*$'\n'}
	elif ((fwd_gpg)) ; then
		remote_gpg_socket=$remote_paths
	else
		remote_ssh_socket=$remote_paths
	fi

	if ((fwd_gpg)) ; then
		local_gpg_socket=$(gpg_socket agent-extra-socket)
		ssh_opts+=(-o "RemoteForward $remote_gpg_socket $local_gpg_socket")
	fi
	if ((fwd_ssh)) ; then
		# The one the local ssh is using, whichever it is: forwarding a
		# different socket than the one that just authenticated us would
		# hand the remote an agent we are not ourselves using.
		ssh_opts+=(-o "RemoteForward $remote_ssh_socket $local_ssh_socket")
	fi
fi

if [[ "$dryrun" ]] ; then
	echo ssh "${ssh_opts[@]@Q}" "${args[@]}" "$@"
else
	ssh "${ssh_opts[@]}" "${args[@]}" "$@"
	exit $?
fi

