summaryrefslogtreecommitdiff
path: root/bin/yubikey-git
blob: 9083097142a8d157d4ff6839b87aeda1c9f93227 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
#!/bin/bash

# Opt this host in to git over ssh with the YubiKey git key, or back out.
#
#   yubikey-git enable     set it up; safe to re-run
#   yubikey-git disable    undo everything enable did
#   yubikey-git status     what is in place
#
# Then, with the YubiKey plugged in, one touch per fetch or push:
#
#   git clone git@git.soarez.net:sz/<name>.git
#
# For a machine that has this repo and nothing else -- one that is not trusted
# with a key of its own. Where a private layer is installed, its git config
# usually sends that URL elsewhere, and enabling this changes nothing there.
#
# What enable does, all of it undone by disable:
#
#   ~/.ssh/id_ed25519_sk_git        a copy of config/ssh/yubikey-git's key, at
#                                   mode 600, which ssh insists on and git
#                                   cannot store. It is the key's handle, not
#                                   its private half: see that directory's
#                                   config for why it is safe in this repo.
#   ~/.config/ssh/yubikey-git       a link to that directory: the ssh config
#                                   and git.soarez.net's pinned host key
#   ~/.config/ssh/enabled.d/yubikey-git.conf
#                                   a link to its config, which is what turns it
#                                   on: config/ssh/entry includes enabled.d/*.conf
#
# On macOS it also needs Homebrew's OpenSSH, and installs it if Homebrew is
# there: Apple's ssh cannot talk to a security key. git runs whichever ssh is
# first on PATH, and config/env puts Homebrew's ahead of /usr/bin.
#
# Runs under macOS's /bin/bash 3.2.

set -euo pipefail

name="yubikey-git"
key="${HOME}/.ssh/id_ed25519_sk_git"
# The same path config/ssh/entry includes from, not $XDG_CONFIG_HOME.
cfg="${HOME}/.config/ssh"
dir_link="${cfg}/${name}"
conf_link="${cfg}/enabled.d/${name}.conf"

die() { >&2 echo "${name}: $*"; exit 1; }

# This script's real directory: ~/.bin/yubikey-git is a link into the repo, and
# macOS's readlink has no -f before 13.
resolve_repo() {
  local src="$0" dir
  while [[ -L "${src}" ]]; do
    dir="$(cd "$(dirname "${src}")" && pwd)"
    src="$(readlink "${src}")"
    [[ "${src}" == /* ]] || src="${dir}/${src}"
  done
  cd "$(dirname "${src}")/.." && pwd
}
src_dir="$(resolve_repo)/config/ssh/${name}"
[[ -f "${src_dir}/config" ]] || die "no ${src_dir}/config -- is this the dotfiles checkout?"

# A link we made points at exactly this target; anything else there is not ours.
ours() { [[ -L "$1" && "$(readlink "$1")" == "$2" ]]; }

need_fido_ssh() {
  local ssh_bin
  ssh_bin="$(command -v ssh || true)"
  if [[ "$(uname)" == Darwin ]]; then
    if [[ "${ssh_bin}" == /usr/bin/ssh || -z "${ssh_bin}" ]]; then
      if [[ ! -x /opt/homebrew/bin/ssh && ! -x /usr/local/bin/ssh ]]; then
        command -v brew > /dev/null || die "Apple's ssh cannot use a security key, and there is no Homebrew to install OpenSSH with"
        echo "installing Homebrew's OpenSSH: Apple's ssh cannot use a security key"
        brew install openssh
      fi
      ssh_bin="$(command -v ssh || true)"
      [[ "${ssh_bin}" != /usr/bin/ssh ]] ||
        die "Homebrew's ssh is installed but /usr/bin/ssh comes first on PATH; open a new shell and re-run"
    fi
  else
    ssh -Q key 2> /dev/null | grep -qx 'sk-ssh-ed25519@openssh.com' ||
      die "this ssh ($(ssh -V 2>&1)) has no security-key support"
  fi
}

enable() {
  need_fido_ssh

  install -d -m 700 "${HOME}/.ssh"
  if [[ -e "${key}" ]] && ! cmp -s "${key}" "${src_dir}/id_ed25519_sk_git"; then
    die "${key} exists and is a different key; move it aside first"
  fi
  install -m 600 "${src_dir}/id_ed25519_sk_git" "${key}"
  install -m 644 "${src_dir}/id_ed25519_sk_git.pub" "${key}.pub"

  mkdir -p "${cfg}/enabled.d"
  if [[ -e "${dir_link}" || -L "${dir_link}" ]] && ! ours "${dir_link}" "${src_dir}"; then
    die "${dir_link} exists and is not this repo's; move it aside first"
  fi
  ln -sfn "${src_dir}" "${dir_link}"
  if [[ -e "${conf_link}" || -L "${conf_link}" ]] && ! ours "${conf_link}" "${dir_link}/config"; then
    die "${conf_link} exists and is not this repo's; move it aside first"
  fi
  ln -sfn "${dir_link}/config" "${conf_link}"

  # Fail now, not at the first push: a config ssh cannot parse breaks every ssh.
  if ! ssh -G git.soarez.net 2>&1 | grep -qix "identityfile ~/.ssh/id_ed25519_sk_git"; then
    ssh -G git.soarez.net > /dev/null || true
    disable > /dev/null
    die "ssh did not pick the config up, so it is undone; is ~/.ssh/config this repo's config/ssh/entry?"
  fi

  echo "${name}: enabled. With the YubiKey plugged in:"
  echo "  git clone git@git.soarez.net:sz/<name>.git"
}

disable() {
  ours "${conf_link}" "${dir_link}/config" && rm "${conf_link}"
  ours "${dir_link}" "${src_dir}" && rm "${dir_link}"
  if [[ -f "${key}" ]] && cmp -s "${key}" "${src_dir}/id_ed25519_sk_git"; then
    rm -f "${key}" "${key}.pub"
  fi
  echo "${name}: disabled"
}

status() {
  local on=y
  if ours "${conf_link}" "${dir_link}/config"; then echo "ssh config:  enabled (${conf_link})"; else echo "ssh config:  not enabled"; on=""; fi
  if ours "${dir_link}" "${src_dir}"; then echo "config dir:  ${dir_link}"; else echo "config dir:  missing"; on=""; fi
  if [[ -f "${key}" ]] && cmp -s "${key}" "${src_dir}/id_ed25519_sk_git"; then echo "key handle:  ${key}"; else echo "key handle:  missing"; on=""; fi
  echo "ssh:         $(command -v ssh) ($(ssh -V 2>&1))"
  [[ -n "${on}" ]]
}

case "${1:-}" in
  enable)  enable ;;
  disable) disable ;;
  status)  status ;;
  *) >&2 echo "usage: ${name} enable|disable|status"; exit 2 ;;
esac