Setup-keyring.sh

From Omnia
Jump to navigation Jump to search

setup-keyring.sh

#!/usr/bin/env bash
#
# setup-keyring.sh -- provision a Secret Service credential store for GitHub
# Copilot CLI on a headless Ubuntu box (no desktop session).
#
# Copilot CLI stores its OAuth token in whatever owns the D-Bus name
# org.freedesktop.secrets. Its bundled native module (prebuilds/linux-x64/
# runtime.node) speaks that protocol directly over D-Bus via zbus -- it does
# NOT link libsecret -- so any conforming provider satisfies it. On a GUI
# desktop that provider is gnome-keyring, started and unlocked by the
# graphical login. Over plain SSH nothing starts it, the name goes unowned,
# and the CLI reports "System vault not available" and offers to fall back to
# storing the token in plaintext in the config file.
#
# Target verified against: Ubuntu 24.04.5 LTS, SSH/tty session, systemd user
# bus live at $XDG_RUNTIME_DIR/bus.
#
# Usage:
#   ./setup-keyring.sh system    # root: apt packages + linger  (run once per host)
#   ./setup-keyring.sh user      # you:  create + unlock keyring (once per reboot)
#   ./setup-keyring.sh hook      # you:  install the shell rc helper (once)
#   ./setup-keyring.sh verify    # check that the whole chain works
#   ./setup-keyring.sh all       # system + hook + user + verify
#
set -euo pipefail

TARGET_USER="${SUDO_USER:-${USER:-$(id -un)}}"
ENV_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/copilot-keyring.env"
HOOK_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/copilot-keyring.sh"

# Pull in the GStreamer/GTK media stack that gcr4 recommends? Almost certainly
# not on a headless box: it is 19 extra packages that only matter for rendering
# a graphical unlock prompt we will never show.
NO_RECOMMENDS=1

# libpam-gnome-keyring auto-unlocks the keyring using your login password, but
# only when PAM actually sees a password. SSH public-key auth never supplies
# one, so this is off by default. Turn it on only if you SSH in with a password.
WANT_PAM=0

say()  { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[!]\033[0m %s\n' "$*" >&2; }
die()  { printf '\033[1;31m[x]\033[0m %s\n' "$*" >&2; exit 1; }

# ---------------------------------------------------------------------------
# 1. System packages + lingering  (requires root)
# ---------------------------------------------------------------------------
#
# gnome-keyring    the daemon itself; owns org.freedesktop.secrets. The only
#                  package here Copilot CLI genuinely requires.
# libsecret-tools  provides secret-tool, used by do_verify to prove the store
#                  works. Drop it if you don't want the self-test.
# libsecret-1-0    a hard dependency of libsecret-tools; named explicitly only
#                  so the intent is visible. Copilot CLI never loads it.
#
# Everything else in the install is pulled in as a hard dependency (gcr, gcr4,
# p11-kit, pinentry-gnome3, and GTK4 via gcr4 -- unavoidable, gcr4 links it).
#
do_system() {
  [[ $EUID -eq 0 ]] || die "run this section as root: sudo $0 system"

  local pkgs=(gnome-keyring libsecret-1-0 libsecret-tools)
  local flags=(-y)
  (( NO_RECOMMENDS )) && flags+=(--no-install-recommends)
  (( WANT_PAM )) && pkgs+=(libpam-gnome-keyring)

  say "Installing: ${pkgs[*]}"
  DEBIAN_FRONTEND=noninteractive apt-get update -qq
  DEBIAN_FRONTEND=noninteractive apt-get install "${flags[@]}" "${pkgs[@]}"

  # Without lingering, systemd tears down the user slice when the last SSH
  # session closes -- taking the keyring daemon and the user bus with it. The
  # VS Code server outlives individual SSH sessions, so it would be left
  # pointing at a bus that no longer has a secrets provider.
  say "Enabling lingering for $TARGET_USER"
  loginctl enable-linger "$TARGET_USER"

  if (( WANT_PAM )); then
    warn "PAM module installed but NOT wired up. To enable, add to /etc/pam.d/sshd:"
    warn "    auth     optional  pam_gnome_keyring.so"
    warn "    session  optional  pam_gnome_keyring.so auto_start"
    warn "Only effective for password-based SSH logins."
  fi

  say "System setup done. Now run (as $TARGET_USER, NOT root):  $0 user"
}

# ---------------------------------------------------------------------------
# helper: is something currently answering on org.freedesktop.secrets?
# ---------------------------------------------------------------------------
secrets_up() {
  [[ -n "${DBUS_SESSION_BUS_ADDRESS:-}" ]] || return 1
  local out
  out=$(gdbus call --session \
          --dest org.freedesktop.DBus \
          --object-path /org/freedesktop/DBus \
          --method org.freedesktop.DBus.NameHasOwner \
          org.freedesktop.secrets 2>/dev/null) || return 1
  [[ $out == *true* ]]
}

# ---------------------------------------------------------------------------
# 2. Create and unlock the keyring  (as your own user, once per reboot)
# ---------------------------------------------------------------------------
do_user() {
  [[ $EUID -ne 0 ]] || die "run this section as yourself, not root"
  command -v gnome-keyring-daemon >/dev/null || die "gnome-keyring not installed; run: sudo $0 system"

  if [[ -z "${DBUS_SESSION_BUS_ADDRESS:-}" ]]; then
    die "No D-Bus session bus. Expected $XDG_RUNTIME_DIR/bus -- log in via SSH normally, or wrap with: dbus-run-session -- $0 user"
  fi

  if secrets_up; then
    say "Secret Service already running and reachable. Nothing to do."
    return 0
  fi

  local first_run=0
  [[ -e "$HOME/.local/share/keyrings/login.keyring" ]] || first_run=1
  if (( first_run )); then
    say "No login keyring exists yet -- it will be CREATED with the password you type now."
    say "Store that password somewhere you can retrieve it; it encrypts the keyring at rest."
  else
    say "Unlocking existing login keyring."
  fi

  local pw
  read -r -s -p "Keyring password: " pw; echo
  if (( first_run )); then
    local pw2
    read -r -s -p "Confirm password: " pw2; echo
    [[ "$pw" == "$pw2" ]] || die "Passwords did not match."
  fi
  [[ -n "$pw" ]] || warn "Empty password: the keyring will auto-unlock, but is then no better protected than a plaintext file."

  mkdir -p "$(dirname "$ENV_FILE")"
  # --components=secrets deliberately excludes the ssh agent, so this will not
  # hijack SSH_AUTH_SOCK and break your existing agent forwarding.
  # The password is read from stdin; EOF terminates it.
  printf '%s' "$pw" | gnome-keyring-daemon --daemonize --unlock --components=secrets \
    > "$ENV_FILE" 2>/dev/null || die "gnome-keyring-daemon failed to start"
  unset pw pw2
  chmod 600 "$ENV_FILE"

  # shellcheck disable=SC1090
  set -a; source "$ENV_FILE"; set +a

  secrets_up || die "Daemon started but org.freedesktop.secrets is still unowned. Check: busctl --user list | grep secret"
  say "Secret Service is up. Env cached at $ENV_FILE"
  say "Because lingering is on, this survives SSH disconnects -- redo only after a reboot."
  warn "Reload the VS Code window now so the Copilot CLI re-checks for a credential store."
}

# ---------------------------------------------------------------------------
# 3. Shell hook -- re-export the daemon env in new shells, warn if locked
# ---------------------------------------------------------------------------
do_hook() {
  mkdir -p "$(dirname "$HOOK_FILE")"
  cat > "$HOOK_FILE" <<'HOOK'
# Copilot CLI credential store (gnome-keyring over D-Bus Secret Service).
# Sourced from ~/.bashrc; see ~/.copilot/setup-keyring.sh
_copilot_keyring_env="${XDG_CONFIG_HOME:-$HOME/.config}/copilot-keyring.env"
[ -r "$_copilot_keyring_env" ] && . "$_copilot_keyring_env" 2>/dev/null
export GNOME_KEYRING_CONTROL

copilot-keyring-unlock() { "$HOME/.copilot/setup-keyring.sh" user; }

copilot-keyring-status() {
  if gdbus call --session --dest org.freedesktop.DBus \
       --object-path /org/freedesktop/DBus \
       --method org.freedesktop.DBus.NameHasOwner \
       org.freedesktop.secrets 2>/dev/null | grep -q true
  then echo "keyring: unlocked"; else echo "keyring: LOCKED -- run copilot-keyring-unlock"; fi
}

# Interactive login shells only: nudge, don't nag or block.
case $- in *i*)
  if [ -z "${COPILOT_KEYRING_QUIET:-}" ] && ! gdbus call --session \
       --dest org.freedesktop.DBus --object-path /org/freedesktop/DBus \
       --method org.freedesktop.DBus.NameHasOwner org.freedesktop.secrets \
       2>/dev/null | grep -q true
  then
    printf '\033[1;33m[!]\033[0m Copilot keyring locked. Run: copilot-keyring-unlock\n' >&2
  fi
  ;;
esac
unset _copilot_keyring_env
HOOK

  local rc="$HOME/.bashrc" line=". \"\${XDG_CONFIG_HOME:-\$HOME/.config}/copilot-keyring.sh\""
  if ! grep -qF 'copilot-keyring.sh' "$rc" 2>/dev/null; then
    printf '\n# GitHub Copilot CLI credential store\n%s\n' "$line" >> "$rc"
    say "Appended hook to $rc"
  else
    say "Hook already present in $rc"
  fi
  say "Wrote $HOOK_FILE"
}

# ---------------------------------------------------------------------------
# 4. Verify the full chain
# ---------------------------------------------------------------------------
do_verify() {
  local fail=0

  for b in gnome-keyring-daemon secret-tool; do
    if command -v "$b" >/dev/null; then say "found $b"; else warn "MISSING $b"; fail=1; fi
  done

  [[ "$(loginctl show-user "$TARGET_USER" -p Linger --value 2>/dev/null)" == "yes" ]] \
    && say "lingering enabled" || { warn "lingering NOT enabled"; fail=1; }

  if secrets_up; then say "org.freedesktop.secrets has an owner"; else
    warn "no Secret Service on the bus -- run: $0 user"; fail=1
  fi

  # Round-trip a throwaway secret through libsecret, which is exactly the path
  # Copilot CLI uses. This also proves the 'default' collection alias resolves.
  if command -v secret-tool >/dev/null && secrets_up; then
    if printf 'roundtrip-ok' | secret-tool store --label='copilot-keyring-selftest' \
         service copilot-selftest account probe 2>/dev/null \
       && [[ "$(secret-tool lookup service copilot-selftest account probe 2>/dev/null)" == "roundtrip-ok" ]]
    then
      say "store/lookup round-trip OK"
      secret-tool clear service copilot-selftest account probe 2>/dev/null || true
    else
      warn "round-trip FAILED -- keyring may be locked or the default collection alias is unset"
      fail=1
    fi
  fi

  (( fail )) && { warn "verification incomplete"; return 1; }
  say "All good. Run 'copilot' and re-authenticate; the token will land in the keyring."
}

case "${1:-all}" in
  system) do_system ;;
  user)   do_user ;;
  hook)   do_hook ;;
  verify) do_verify ;;
  all)
    if [[ $EUID -eq 0 ]]; then do_system
    else
      command -v gnome-keyring-daemon >/dev/null || die "run 'sudo $0 system' first"
      do_hook; do_user; do_verify
    fi
    ;;
  *) die "usage: $0 {system|user|hook|verify|all}" ;;
esac