From 4f40b8ea2f224132dddde4f505191c3662620741 Mon Sep 17 00:00:00 2001 From: Igor Soarez Date: Sun, 25 Oct 2026 17:56:18 +0200 Subject: yubikey-git: opt a host in to git over ssh at git.soarez.net with the YubiKey git key; ssh entry includes enabled.d/ --- AGENTS.md | 18 +++- bin/yubikey-git | 138 +++++++++++++++++++++++++++ config/ssh/entry | 12 ++- config/ssh/yubikey-git/config | 25 +++++ config/ssh/yubikey-git/id_ed25519_sk_git | 9 ++ config/ssh/yubikey-git/id_ed25519_sk_git.pub | 1 + config/ssh/yubikey-git/known_hosts | 1 + 7 files changed, 197 insertions(+), 7 deletions(-) create mode 100755 bin/yubikey-git create mode 100644 config/ssh/yubikey-git/config create mode 100644 config/ssh/yubikey-git/id_ed25519_sk_git create mode 100644 config/ssh/yubikey-git/id_ed25519_sk_git.pub create mode 100644 config/ssh/yubikey-git/known_hosts 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/.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/.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/.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 -- cgit v1.2.3