From 5e7c3293aec53845d3e92c15e0019abb8b5f4a2d Mon Sep 17 00:00:00 2001 From: Dict Xiong Date: Thu, 13 Aug 2026 11:40:29 +0800 Subject: [PATCH] feat(riot): add GPG agent forwarding Signed-off-by: Dict Xiong --- scripts/riot | 139 ++++++++++++++++++++++++++++++++++++++++- tools/test-riot-gpg.sh | 130 ++++++++++++++++++++++++++++++++++++++ tools/test.zsh | 1 + 3 files changed, 269 insertions(+), 1 deletion(-) create mode 100755 tools/test-riot-gpg.sh diff --git a/scripts/riot b/scripts/riot index 09f62fb..0fb21e6 100755 --- a/scripts/riot +++ b/scripts/riot @@ -5,6 +5,7 @@ source "$THIS_DIR/../tools/common.sh" RIOT_TRUST_CLIENT=${RIOT_TRUST_CLIENT:-${DFS_TRUST:-0}} RIOT_TRUST_SERVER=${RIOT_TRUST_SERVER:-0} EXTRA_SSH_OPTIONS=() +GPG_FORWARD=0 # config RIOT_CONFIG_FILES=( @@ -135,6 +136,129 @@ parse_remote() { fi } +check_local_gpg_agent() { + LOCAL_GPG_EXTRA_SOCKET="" + GPG_FORWARD_ERROR="" + + if ! command -v gpgconf > /dev/null 2>&1 || ! command -v gpg-connect-agent > /dev/null 2>&1; then + GPG_FORWARD_ERROR="gpgconf or gpg-connect-agent is not available" + return 1 + fi + + local agent_info + agent_info=$(gpg-connect-agent --no-autostart 'GETINFO pid' /bye 2>/dev/null || true) + if ! grep -qE '^D [1-9][0-9]*$' <<< "$agent_info"; then + GPG_FORWARD_ERROR="local gpg-agent is not running" + return 1 + fi + + LOCAL_GPG_EXTRA_SOCKET=$(gpgconf --list-dirs agent-extra-socket 2>/dev/null || true) + if [[ -z "$LOCAL_GPG_EXTRA_SOCKET" || "$LOCAL_GPG_EXTRA_SOCKET" != /* \ + || "$LOCAL_GPG_EXTRA_SOCKET" == *:* || "$LOCAL_GPG_EXTRA_SOCKET" == *$'\r'* \ + || "$LOCAL_GPG_EXTRA_SOCKET" == *$'\n'* || ! -S "$LOCAL_GPG_EXTRA_SOCKET" ]]; then + GPG_FORWARD_ERROR="local gpg-agent extra socket is unavailable" + return 1 + fi + + local extra_info + extra_info=$(gpg-connect-agent --raw-socket "$LOCAL_GPG_EXTRA_SOCKET" 'GETINFO version' /bye 2>/dev/null || true) + if ! grep -qE '^D [^[:space:]]+' <<< "$extra_info"; then + GPG_FORWARD_ERROR="local gpg-agent extra socket exists but is not accepting connections" + return 1 + fi +} + +probe_remote_gpg_socket() { + REMOTE_GPG_SOCKET="" + + # The probe must be an independent connection: reusing or creating a + # multiplex master here races with the immediately following login. + local query_cmd=(ssh "-S" "none" "-o" "ClearAllForwardings=yes" "-o" "RequestTTY=no") + if [[ -n "$PORT" ]]; then + query_cmd+=("-p" "$PORT") + fi + query_cmd+=( + "${SSH_OPTIONS[@]}" + "${EXTRA_SSH_OPTIONS[@]}" + "-T" + "$USERNAME${USERNAME:+@}$SERVER" + 'socket=$(gpgconf --list-dirs agent-socket 2>/dev/null) || exit 10 +case "$socket" in /*/S.gpg-agent) ;; *) exit 11;; esac +case "$socket" in *:*) exit 11;; esac +case "$socket" in *" +"*) exit 11;; esac +carriage_return=$(printf "\r") +case "$socket" in *"$carriage_return"*) exit 11;; esac +if [ -e "$socket" ] && [ ! -S "$socket" ]; then exit 12; fi +if [ -S "$socket" ] && command -v gpg-connect-agent >/dev/null 2>&1; then + agent_mode=$(gpg-connect-agent --raw-socket "$socket" "GETINFO restricted" /bye 2>/dev/null || true) + case "$agent_mode" in *"D 1"*) exit 13;; esac +fi +systemd_socket=0 +if command -v systemctl >/dev/null 2>&1 && systemctl --user is-active --quiet gpg-agent.socket >/dev/null 2>&1; then + systemd_socket=1 +fi +gpgconf --kill gpg-agent >/dev/null 2>&1 || exit 14 +rm -f "$socket" || exit 15 +printf "%s\n%s\n" "$socket" "$systemd_socket"' + ) + + local output status + if output=$("${query_cmd[@]}"); then + status=0 + else + status=$? + fi + case "$status" in + 0) ;; + 10) GPG_FORWARD_ERROR="gpgconf is unavailable on the remote host" ;; + 11) GPG_FORWARD_ERROR="remote gpgconf returned an invalid agent socket" ;; + 12) GPG_FORWARD_ERROR="refusing to remove the non-socket remote gpg-agent path" ;; + 13) GPG_FORWARD_ERROR="another forwarded gpg-agent is already using the remote socket; close that session first" ;; + 14) GPG_FORWARD_ERROR="failed to stop the remote gpg-agent (the socket may belong to another forwarding session)" ;; + 15) GPG_FORWARD_ERROR="failed to remove the stale remote gpg-agent socket" ;; + *) GPG_FORWARD_ERROR="failed to query or clean the remote gpg-agent socket (ssh status $status)" ;; + esac + if [[ "$status" != "0" ]]; then + return 1 + fi + + local remote_systemd_socket + REMOTE_GPG_SOCKET=${output%%$'\n'*} + remote_systemd_socket=${output#*$'\n'} + if [[ -z "$REMOTE_GPG_SOCKET" || "$REMOTE_GPG_SOCKET" == *$'\r'* || "$REMOTE_GPG_SOCKET" == *$'\n'* \ + || "$REMOTE_GPG_SOCKET" != /*/S.gpg-agent || "$REMOTE_GPG_SOCKET" == *:* \ + || "$remote_systemd_socket" != "0" && "$remote_systemd_socket" != "1" ]]; then + GPG_FORWARD_ERROR="remote gpgconf returned an invalid agent socket: $output" + return 1 + fi + if [[ "$remote_systemd_socket" == "1" ]]; then + fmt_warning "remote gpg-agent.socket is active and may race with GPG forwarding; consider disabling its socket activation" + fi +} + +prepare_gpg_forwarding() { + if [[ "$DFS_DRY_RUN" == "1" ]]; then + REMOTE_GPG_SOCKET="" + LOCAL_GPG_EXTRA_SOCKET="" + else + if ! check_local_gpg_agent; then + fmt_fatal "cannot forward gpg-agent: $GPG_FORWARD_ERROR" + fi + if ! probe_remote_gpg_socket; then + fmt_fatal "cannot forward gpg-agent: $GPG_FORWARD_ERROR" + fi + fi + + SSH_OPTIONS+=( + # The probe already removed the old socket. Do not let the main SSH + # connection unlink a path recreated during the gap between them. + "-o" "StreamLocalBindUnlink=no" + "-o" "ExitOnForwardFailure=yes" + "-R" "$REMOTE_GPG_SOCKET:$LOCAL_GPG_EXTRA_SOCKET" + ) +} + print_cmd() { local output="" for s in "${CMD[@]}"; do @@ -303,8 +427,10 @@ remove_hostkey() { # main print_help() { - fmt_info "usage: $0 [-Ddhlqt] [--dry-run] [--dev] [--help] [--lite] [--quite] [--trust] [--tmux] [--password] [[-o ssh-option]...] remote [command] [--] [command-args]" + fmt_info "usage: $0 [-Ddghlqt] [--dry-run] [--dev] [--gpg] [--help] [--lite] [--quite] [--trust] [--tmux] [--password] [[-o ssh-option]...] remote [command] [--] [command-args]" cat < "$MOCK_BIN/gpgconf" <<'EOF' +#!/usr/bin/env bash +printf 'gpgconf %s\n' "$*" >> "$MOCK_GPG_LOG" +case "$*" in + '--list-dirs agent-extra-socket') printf '%s\n' "$MOCK_LOCAL_SOCKET" ;; + '--list-dirs agent-socket') printf '%s\n' "$MOCK_REMOTE_SOCKET" ;; + '--kill gpg-agent') exit "${MOCK_KILL_STATUS:-0}" ;; + *) exit 1 ;; +esac +EOF + +cat > "$MOCK_BIN/gpg-connect-agent" <<'EOF' +#!/usr/bin/env bash +printf 'gpg-connect-agent %s\n' "$*" >> "$MOCK_GPG_LOG" +case "$*" in + *'GETINFO pid'*) printf 'D 123\nOK\n' ;; + *'GETINFO version'*) + [[ "${MOCK_LOCAL_LIVE:-1}" == 1 ]] || exit 1 + printf 'D 2.4.0\nOK\n' + ;; + *'GETINFO restricted'*) printf 'D %s\nOK\n' "${MOCK_REMOTE_RESTRICTED:-0}" ;; + *) exit 1 ;; +esac +EOF + +cat > "$MOCK_BIN/systemctl" <<'EOF' +#!/usr/bin/env bash +[[ "${MOCK_SYSTEMD_ACTIVE:-0}" == 1 ]] +EOF + +cat > "$MOCK_BIN/ssh" <<'EOF' +#!/usr/bin/env bash +{ + printf 'CALL\n' + printf 'ARG=%s\n' "$@" +} >> "$MOCK_SSH_LOG" + +is_probe=0 +last_arg='' +for arg in "$@"; do + [[ "$arg" == '-T' ]] && is_probe=1 + last_arg=$arg +done +if [[ "$is_probe" == 1 ]]; then + sh -c "$last_arg" +fi +EOF + +chmod +x "$MOCK_BIN/gpgconf" "$MOCK_BIN/gpg-connect-agent" "$MOCK_BIN/systemctl" "$MOCK_BIN/ssh" + +run_riot() { + HOME="$MOCK_HOME" RIOT_TRUST_CLIENT=0 PATH="$MOCK_BIN:$PATH" "$RIOT" "$@" +} + +expect_failure() { + local expected=$1 + shift + if run_riot "$@" > "$TEST_DIR/out" 2> "$TEST_DIR/err"; then + echo "expected riot to fail: $*" >&2 + exit 1 + fi + grep -Fq "$expected" "$TEST_DIR/err" +} + +# Dry-run must not inspect or mutate either host. +: > "$MOCK_GPG_LOG" +: > "$MOCK_SSH_LOG" +DFS_DRY_RUN=1 run_riot -g example.test > "$TEST_DIR/out" 2> "$TEST_DIR/err" +grep -Fq ':' "$TEST_DIR/out" +[[ ! -s "$MOCK_GPG_LOG" && ! -s "$MOCK_SSH_LOG" ]] + +# A socket inode without a listener must be rejected locally. +make_stale_socket "$MOCK_LOCAL_SOCKET" +MOCK_LOCAL_LIVE=0 expect_failure 'extra socket exists but is not accepting connections' -g example.test + +# -g is valid only for an interactive SSH login. +expect_failure 'only supported for interactive SSH login' -g example.test scp ./a ./b +expect_failure 'only supported for interactive SSH login' -g example.test ssh -- true + +# Never remove a regular file merely because it has the expected basename. +MOCK_LOCAL_LIVE=1 +printf 'keep me\n' > "$MOCK_REMOTE_SOCKET" +expect_failure 'refusing to remove the non-socket remote gpg-agent path' -g example.test +grep -Fqx 'keep me' "$MOCK_REMOTE_SOCKET" + +# A restricted agent at the remote socket represents another forwarding session. +make_stale_socket "$MOCK_REMOTE_SOCKET" +MOCK_REMOTE_RESTRICTED=1 expect_failure 'another forwarded gpg-agent is already using the remote socket' -g example.test + +# A normal remote agent can be cleaned up; systemd activation is reported. +make_stale_socket "$MOCK_REMOTE_SOCKET" +: > "$MOCK_SSH_LOG" +MOCK_REMOTE_RESTRICTED=0 MOCK_SYSTEMD_ACTIVE=1 run_riot -g example.test > "$TEST_DIR/out" 2> "$TEST_DIR/err" +grep -Fq 'remote gpg-agent.socket is active' "$TEST_DIR/err" +grep -Fq 'ARG=none' "$MOCK_SSH_LOG" +grep -Fq 'ARG=ClearAllForwardings=yes' "$MOCK_SSH_LOG" +grep -Fq 'ARG=StreamLocalBindUnlink=no' "$MOCK_SSH_LOG" +grep -Fq "ARG=$MOCK_REMOTE_SOCKET:$MOCK_LOCAL_SOCKET" "$MOCK_SSH_LOG" +[[ ! -e "$MOCK_REMOTE_SOCKET" ]] + +echo 'riot gpg forwarding tests passed' diff --git a/tools/test.zsh b/tools/test.zsh index 6da7a64..7f57bf5 100644 --- a/tools/test.zsh +++ b/tools/test.zsh @@ -34,6 +34,7 @@ dogo doll dfs cd tools/test-getopts.sh +tools/test-riot-gpg.sh tools/common.sh get_os_name test $(echo y | tools/common.sh ask_for_yN "test") = "1" test $(echo n | tools/common.sh ask_for_yN "test") = "0"