summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-10-25 17:56:18 +0200
committerIgor Soarez <igor@soarez.org>2026-10-25 17:56:18 +0200
commit4f40b8ea2f224132dddde4f505191c3662620741 (patch)
treec3b1727da042e808b6445db2f6a2c089c2e38541
parent9064d88bf109cd51ec53bd50815623b71d19e832 (diff)
yubikey-git: opt a host in to git over ssh at git.soarez.net with the YubiKey git key; ssh entry includes enabled.d/
-rw-r--r--AGENTS.md18
-rwxr-xr-xbin/yubikey-git138
-rw-r--r--config/ssh/entry12
-rw-r--r--config/ssh/yubikey-git/config25
-rw-r--r--config/ssh/yubikey-git/id_ed25519_sk_git9
-rw-r--r--config/ssh/yubikey-git/id_ed25519_sk_git.pub1
-rw-r--r--config/ssh/yubikey-git/known_hosts1
7 files changed, 197 insertions, 7 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 611a1cd..2658b34 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -30,9 +30,19 @@ certificate in the transparency logs -- such as the request inbox
nothing and costs every machine a hand-written config file; what protects that
endpoint is that it stores nothing without a signature from an authorised key.
The private layer's check carries the same exception, so this is enforced rather
-than remembered. It does not extend to hosts, addresses or internal-only names. Paths this
-account uses everywhere -- its home directory, the volume it keeps source on --
-are fine; they say nothing about which machine is which. Anything that would
+than remembered. It does not extend to hosts, addresses or internal-only names.
+
+Two things follow from that exception, and are allowed for the same reason. Such
+an endpoint's ssh host key may be pinned here, since anyone can read it off the
+endpoint with `ssh-keyscan`. And the handle of a security-key ssh key may live
+here: it is not a credential on its own, because the private half never leaves
+the hardware, which signs nothing without a touch. `config/ssh/yubikey-git/`
+holds both, so that a machine with only this repo can use git over
+git.soarez.net (`bin/yubikey-git`). A plain ssh key is a credential and never
+qualifies.
+
+Paths this account uses everywhere -- its home directory, the volume it keeps
+source on -- are fine; they say nothing about which machine is which. Anything that would
fail that test belongs in a private layer instead, and reaches a machine
through the hooks below.
@@ -169,6 +179,7 @@ tracked config says so, and in no repo:
| `~/.localenv` | environment for every shell: PATH, exported variables. POSIX sh, no forks | `config/env`, before it drops `_env_prepend`/`_env_append`, so those work here |
| `~/.localprofile` | interactive setup: aliases, prompt, anything that forks | `config/profile`, last |
| `~/.config/ssh/config-local` | ssh, this host only. See `config/ssh/config-local-example` | `config/ssh/entry`, before the shared defaults |
+| `~/.config/ssh/enabled.d/*.conf` | ssh configs from this repo that this host opted in to, as links -- `bin/yubikey-git enable` adds one | `config/ssh/entry`, after the private layer's and before `config-local` |
| `~/.config/git/config-local` | git, this host only -- the work identity, `includeIf` blocks. See `config/git/config-local-example` | `config/git/config`, after the `[user]` block above it, and git takes the last value |
| `~/.config/hammerspoon/local.lua` | Hammerspoon: rebinding a hotkey set here shadows it, and `capslock_off()` gives this machine its Caps Lock back. `require("dotlib")` for the same helpers init.lua uses. See `config/hammerspoon/local-example.lua` | `config/hammerspoon/init.lua`, last, in a `pcall` |
| `~/.tmux.conf.local` | tmux, this host only: loaded last, so it can override anything, colours included | `config/tmux.conf`, last, with `source-file -q` |
@@ -414,6 +425,7 @@ The `bin/` directory contains various utility scripts for:
- Notification sending (`notify`)
- Development tools (`vi` wrapper, `smack`, `pyexec`)
- System utilities (`wifi`, `default-browser`, `truecolour-test`, `open` — macOS-style `open` for Linux desktops; hands over to `/usr/bin/open` on macOS)
+- git over ssh with a YubiKey, opted in per host (`yubikey-git enable|disable|status`; see `config/ssh/yubikey-git/config`)
- App settings that no `defaults` key reaches (`wispr-keys` — Flow's dictation shortcuts; see the Keyboard section for why it lives here and not in `link/`)
## Development Notes
diff --git a/bin/yubikey-git b/bin/yubikey-git
new file mode 100755
index 0000000..9083097
--- /dev/null
+++ b/bin/yubikey-git
@@ -0,0 +1,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
diff --git a/config/ssh/entry b/config/ssh/entry
index e1b6994..a8cdf2b 100644
--- a/config/ssh/entry
+++ b/config/ssh/entry
@@ -7,15 +7,19 @@
# a set of machines. First, so host-key pinning
# there cannot be undone by anything read after
# it.
-# 2. ~/.config/ssh/config-local This host only; not in any repo, never
+# 2. ~/.config/ssh/enabled.d/ Configs from this repo that a host opted in
+# to, as links: bin/yubikey-git enable puts
+# one here.
+# 3. ~/.config/ssh/config-local This host only; not in any repo, never
# synced. See config/ssh/config-local-example.
-# 3. Shared defaults, below Last, so either of the above can override them.
+# 4. Shared defaults, below Last, so any of the above can override them.
#
-# Both includes are optional: ssh skips an Include that matches no file, so on a
-# machine with neither this file configures nothing at all. No key is named
+# Every include is optional: ssh skips an Include that matches no file, so on a
+# machine with none of them this file configures nothing at all. No key is named
# anywhere; ssh's default ~/.ssh/id_ed25519 is whatever that machine installs.
Include ~/.config/ssh/config-fleet
+Include ~/.config/ssh/enabled.d/*.conf
Include ~/.config/ssh/config-local
# Shared defaults for every host go here, under `Host *`. There are none yet.
diff --git a/config/ssh/yubikey-git/config b/config/ssh/yubikey-git/config
new file mode 100644
index 0000000..a878c92
--- /dev/null
+++ b/config/ssh/yubikey-git/config
@@ -0,0 +1,25 @@
+# git over ssh to git.soarez.net with the YubiKey git key: git clone
+# git@git.soarez.net:sz/<name>.git, one touch of the key per fetch or push.
+#
+# Read only on a host that opted in: `yubikey-git enable` links this file into
+# ~/.config/ssh/enabled.d/, which config/ssh/entry includes. See bin/yubikey-git.
+#
+# The key is id_ed25519_sk_git beside this file, a FIDO2 key whose private half
+# never leaves the YubiKey. The file is only its handle: useless without the
+# YubiKey, and the YubiKey signs nothing without a touch. git.soarez.net accepts
+# it for git on the repositories and nothing else.
+
+Host git.soarez.net
+ User git
+ IdentityFile ~/.ssh/id_ed25519_sk_git
+ IdentitiesOnly yes
+ # Pinned, not learned: on a machine we do not trust, the first connection is
+ # exactly the one an attacker would intercept.
+ HostKeyAlias git.soarez.net
+ StrictHostKeyChecking yes
+ UserKnownHostsFile ~/.config/ssh/yubikey-git/known_hosts
+ # Never share a connection: a master left open would let anything on this
+ # machine fetch or push without a touch.
+ ControlMaster no
+ ControlPath none
+ ForwardAgent no
diff --git a/config/ssh/yubikey-git/id_ed25519_sk_git b/config/ssh/yubikey-git/id_ed25519_sk_git
new file mode 100644
index 0000000..1dfc9cb
--- /dev/null
+++ b/config/ssh/yubikey-git/id_ed25519_sk_git
@@ -0,0 +1,9 @@
+-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAASgAAABpzay1zc2
+gtZWQyNTUxOUBvcGVuc3NoLmNvbQAAACCwffHChtXGxpXW4ot5J5QgCuVzZQish4mCeejc
+CV7CmgAAAARzc2g6AAAA8NofV7LaH1eyAAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY2
+9tAAAAILB98cKG1cbGldbii3knlCAK5XNlCKyHiYJ56NwJXsKaAAAABHNzaDoBAAAAgLrY
+C5HJdImLKhBgt4VkfTVjThXtUYWSCSrSJlO0o+ljm07iqPzc60Z8A8lKVrmw4lUd0wMjv0
+R25REIhhh+A+whxQK4mVl2mLDZk1qt0OoO0tEVR7TKydUaBR/Mk1nZHAb++a7WB/oFZGd6
+N+WSsiUNg0xtorBq/Od79olI/0RyAAAAAAAAABB5dWJpa2V5LTEyNTk1NTk5AQ==
+-----END OPENSSH PRIVATE KEY-----
diff --git a/config/ssh/yubikey-git/id_ed25519_sk_git.pub b/config/ssh/yubikey-git/id_ed25519_sk_git.pub
new file mode 100644
index 0000000..be46ed8
--- /dev/null
+++ b/config/ssh/yubikey-git/id_ed25519_sk_git.pub
@@ -0,0 +1 @@
+sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAILB98cKG1cbGldbii3knlCAK5XNlCKyHiYJ56NwJXsKaAAAABHNzaDo= yubikey-12595599
diff --git a/config/ssh/yubikey-git/known_hosts b/config/ssh/yubikey-git/known_hosts
new file mode 100644
index 0000000..0f62b4b
--- /dev/null
+++ b/config/ssh/yubikey-git/known_hosts
@@ -0,0 +1 @@
+git.soarez.net ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJCkJM1OCp6YZeNJ4iVBhU66aLAXJTKerkbDzSmVVkvJ