diff --git a/docs/git-signing.md b/docs/git-signing.md index 5f8a705d..ecabd635 100644 --- a/docs/git-signing.md +++ b/docs/git-signing.md @@ -5,8 +5,8 @@ never leaves the vault: git delegates the signing operation to 1Password's own signer binary, which prompts you to approve it. This is the alternative to the hardware-token flow in -[yubikey.md](yubikey.md) — `useYubiKey = true` takes precedence and ignores -everything on this page. +[yubikey.md](yubikey.md) — `useYubiKey = true` takes precedence for signing +new commits and tags. Signature verification trusts both sets of public keys. ## Setup @@ -48,6 +48,13 @@ turns on `commit.gpgsign` / `tag.gpgsign`, and adds the key to `~/.config/git/allowed_signers` so your own commits verify locally. A public key is not a secret, so keeping it in the config is fine. +`allowed_signers` includes the configured `gitSigningKey` and all enrolled +YubiKey public keys regardless of `useYubiKey`, so switching signing backends +does not remove trust in previously signed commits. Keep the YubiKey `.pub` +files in `~/.ssh/` (`id_ed25519_sk_*.pub`, `id_ecdsa_sk_*.pub`, or the legacy +un-suffixed names); chezmoi reads them whenever it renders this file. +The YubiKeys do not need to be plugged in for verification. + Git needs a `key::` prefix to read a literal public key rather than a file path; the template adds it for you, so paste the key exactly as 1Password gives it — with or without a trailing comment. diff --git a/docs/yubikey.md b/docs/yubikey.md index fa67465a..6b3bf8a4 100644 --- a/docs/yubikey.md +++ b/docs/yubikey.md @@ -320,8 +320,11 @@ private key. If you carry multiple YubiKeys, set `user.signingkey` to the specific pubkey you want to sign with (or override per-repo via `git config user.signingkey ~/.ssh/id_ed25519_sk_.pub`). -`allowed_signers` lists **all** per-serial pubkeys so verification works -regardless of which YubiKey signed the commit. +`allowed_signers` lists **all** enrolled per-serial and legacy pubkeys, plus +the configured 1Password `gitSigningKey`, regardless of `useYubiKey`. +Switching signing backends therefore preserves local verification of older +commits. Keep the YubiKey `.pub` files in `~/.ssh/` so chezmoi can continue to +include them; verification does not require a plugged-in YubiKey. ### Add a coworker's key diff --git a/home/dot_config/git/allowed_signers.tmpl b/home/dot_config/git/allowed_signers.tmpl index ccaec153..712b9501 100644 --- a/home/dot_config/git/allowed_signers.tmpl +++ b/home/dot_config/git/allowed_signers.tmpl @@ -6,9 +6,8 @@ {{- /* man ssh-keygen, "ALLOWED SIGNERS" section. */ -}} # Managed by chezmoi. Add additional principals via `yk-git-sign-setup --add`. # Lines starting with '#' are ignored. -{{ if .useYubiKey -}} -{{- /* Inline every per-serial FIDO2 pubkey we find so any plugged-in YubiKey */ -}} -{{- /* verifies. Falls back to the legacy un-suffixed names if present. */ -}} +{{/* Verification keys are independent of the active signing backend. */ -}} +{{- /* Keep every enrolled FIDO2 pubkey, including legacy un-suffixed names. */ -}} {{- $found := false -}} {{- range glob (joinPath .chezmoi.homeDir ".ssh" "id_ed25519_sk_*.pub") -}} {{ $.email }} {{ include . | trim }} @@ -28,11 +27,11 @@ {{ .email }} {{ include $legacyEc | trim }} {{ $found = true -}} {{- end -}} -{{- if not $found }} +{{- if and .useYubiKey (not $found) }} # No FIDO2 SSH pubkey found yet. Run `yk-enroll` and re-run `chezmoi apply`. -{{- end }} -{{- else if .gitSigningKey }} -{{- /* 1Password-held signing key (see docs/wsl.md). The public key is enough */ -}} +{{ end }} +{{- if .gitSigningKey }} +{{- /* 1Password-held signing key (see docs/git-signing.md). The public key is enough */ -}} {{- /* to verify signatures; the private half stays in 1Password. */ -}} {{ .email }} {{ .gitSigningKey | trim }} {{- end }} diff --git a/home/dot_config/shell/completions.d/00-homebrew.bash b/home/dot_config/shell/completions.d/00-homebrew.bash index 609857e5..9f7462a9 100755 --- a/home/dot_config/shell/completions.d/00-homebrew.bash +++ b/home/dot_config/shell/completions.d/00-homebrew.bash @@ -13,7 +13,10 @@ fi # Load Homebrew's bash completions from bash_completion.d directory # These are static completion files provided by Homebrew packages -if command -v brew &>/dev/null; then +# Some (e.g. ykman) require Bash 4.4's `complete -o nosort`. +# Keep shellenv above active on older Bash; load only our fallback completions. +if ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 4))) && + command -v brew &>/dev/null; then HOMEBREW_PREFIX="$(brew --prefix)" if [ -d "${HOMEBREW_PREFIX}/etc/bash_completion.d" ]; then for completion_file in "${HOMEBREW_PREFIX}/etc/bash_completion.d"/*; do diff --git a/home/dot_config/shell/completions.d/README.md b/home/dot_config/shell/completions.d/README.md index 34609e87..7bfcc43b 100644 --- a/home/dot_config/shell/completions.d/README.md +++ b/home/dot_config/shell/completions.d/README.md @@ -7,7 +7,11 @@ This directory contains shell completions and tool initializations for **Bash** ### Homebrew Integration (Priority) - **00-homebrew.bash** / **00-homebrew.zsh** - Homebrew environment and completion setup (loaded first) - - **Bash**: Sources completions from `$(brew --prefix)/etc/bash_completion.d/` + - **Bash 4.4+**: Sources completions from `$(brew --prefix)/etc/bash_completion.d/` + - **Older Bash (including macOS Bash 3.2)**: Initializes Homebrew's environment + but skips this third-party completion directory because some files require + newer features such as `complete -o nosort`. The dotfiles' own completion + initializers still run. Use a newer Bash for the full Homebrew completion set. - **Zsh**: Adds `$(brew --prefix)/share/zsh/site-functions` to `FPATH` - See [Homebrew Shell Completion docs](https://docs.brew.sh/Shell-Completion) for details diff --git a/home/dot_config/shell/config.bash b/home/dot_config/shell/config.bash index a20ae376..50e9ec1f 100755 --- a/home/dot_config/shell/config.bash +++ b/home/dot_config/shell/config.bash @@ -12,8 +12,8 @@ if [[ "${TERM_PROGRAM}" != "vscode" ]]; then projects_path="${HOME}/projects" # Check if current path contains 'projects' (case-insensitive) - # Using bash parameter expansion to convert to lowercase for comparison - if [[ ! "${current_path,,}" =~ "projects" ]]; then + # Character classes also work with macOS's built-in Bash 3.2. + if [[ "${current_path}" != *[Pp][Rr][Oo][Jj][Ee][Cc][Tt][Ss]* ]]; then # Not in projects directory, change to it if it exists if [[ -d "${projects_path}" ]]; then cd "${projects_path}" || true diff --git a/tests/README.md b/tests/README.md index b290ada7..755555ff 100644 --- a/tests/README.md +++ b/tests/README.md @@ -105,6 +105,7 @@ Invoke-Pester -Path ./tests/powershell -Tag "Pipeline" - `test-entra-id-parsing.bats` - Tests Entra ID user parsing - `test-fish-config.bats` - Tests Fish shell configuration loading - `test-git-config-windows.bats` - Tests Git configuration on Windows +- `git-allowed-signers.bats` - Verifies public keys remain trusted across signing modes - `test-shell-startup-logic.bats` - Tests shell initialization logic - `verify-dotfiles.bats` - Verifies applied dotfiles exist diff --git a/tests/bash/git-allowed-signers.bats b/tests/bash/git-allowed-signers.bats new file mode 100644 index 00000000..c224d4e4 --- /dev/null +++ b/tests/bash/git-allowed-signers.bats @@ -0,0 +1,90 @@ +#!/usr/bin/env bats + +setup() { + command -v chezmoi >/dev/null 2>&1 || skip "Chezmoi not installed" + REPO_ROOT="$(cd "${BATS_TEST_DIRNAME}/../.." && pwd)" + TEST_HOME="$BATS_TEST_TMPDIR/home" + TEST_CONFIG="$BATS_TEST_TMPDIR/chezmoi.yaml" + OP_KEY="ssh-ed25519 AAAA1password 1Password" + mkdir -p "$TEST_HOME/.ssh" + printf 'data: {}\n' >"$TEST_CONFIG" + # Resolve shims before changing HOME so mise does not re-bootstrap in fixtures. + CHEZMOI_BIN="$(chezmoi --config "$TEST_CONFIG" execute-template '{{ .chezmoi.executable }}')" +} + +render_signers() { + local use_yubikey="$1" signing_key="${2-$OP_KEY}" + cat >"$TEST_CONFIG" <"$TEST_HOME/.ssh/id_ed25519_sk_11.pub" + printf '%s\n' "$ed2" >"$TEST_HOME/.ssh/id_ed25519_sk_22.pub" + printf '%s\n' "$ec" >"$TEST_HOME/.ssh/id_ecdsa_sk_33.pub" + printf '%s\n' "$legacy_ed" >"$TEST_HOME/.ssh/id_ed25519_sk.pub" + printf '%s\n' "$legacy_ec" >"$TEST_HOME/.ssh/id_ecdsa_sk.pub" + + local expected mode + expected="$(printf 'test@example.com %s\n' "$ed1" "$ed2" "$ec" "$legacy_ed" "$legacy_ec" "$OP_KEY")" + for mode in true false true; do + run render_signers "$mode" + [ "$status" -eq 0 ] + [ "$(signer_entries)" = "$expected" ] + [[ "$output" != *"No FIDO2 SSH pubkey"* ]] + done +} + +@test "allowed-signers: YubiKey keys remain when no 1Password key is configured" { + local key="sk-ssh-ed25519@openssh.com AAAAretained retained" mode + printf '%s\n' "$key" >"$TEST_HOME/.ssh/id_ed25519_sk_11.pub" + + for mode in false true; do + run render_signers "$mode" "" + [ "$status" -eq 0 ] + [ "$(signer_entries)" = "test@example.com $key" ] + done +} + +@test "allowed-signers: 1Password key remains trusted in either signing mode without YubiKey files" { + local mode + for mode in false true; do + run render_signers "$mode" + [ "$status" -eq 0 ] + [ "$(signer_entries)" = "test@example.com $OP_KEY" ] + done +} + +@test "allowed-signers: missing YubiKey guidance is shown only when YubiKey signing is selected" { + run render_signers true + [ "$status" -eq 0 ] + [[ "$output" == *"No FIDO2 SSH pubkey found yet"* ]] + + run render_signers false + [ "$status" -eq 0 ] + [[ "$output" != *"No FIDO2 SSH pubkey"* ]] +} + +@test "allowed-signers: no keys produces no malformed signer entries" { + local mode + for mode in false true; do + run render_signers "$mode" "" + [ "$status" -eq 0 ] + [ -z "$(signer_entries)" ] + done +} diff --git a/tests/bash/test-shell-startup-logic.bats b/tests/bash/test-shell-startup-logic.bats index 0faee319..724bf845 100755 --- a/tests/bash/test-shell-startup-logic.bats +++ b/tests/bash/test-shell-startup-logic.bats @@ -53,16 +53,71 @@ setup() { [ "$status" -eq 0 ] } -@test "test-shell-startup-logic: bash config uses case-insensitive check" { - local config_file="$REPO_ROOT/home/dot_config/shell/config.bash" +run_bash_startup() { + run env -u BASH_ENV HOME="$BATS_TEST_TMPDIR/home" TERM_PROGRAM="${2:-}" \ + /bin/bash --noprofile --norc -c ' + cd -- "$1" || exit + source "$REPO_ROOT/home/dot_config/shell/config.bash" + pwd + ' bash "$1" +} - if [ ! -f "$config_file" ]; then - skip "Bash config not found" - fi +@test "test-shell-startup-logic: bash preserves mixed-case projects paths without startup errors" { + local start="$BATS_TEST_TMPDIR/home/PrOjEcTs/client" + mkdir -p "$start" "$BATS_TEST_TMPDIR/home/projects" - # Should use case-insensitive comparison (either parameter expansion or regex) - run bash -c "grep -q ',,\|[Pp][Rr][Oo][Jj][Ee][Cc][Tt][Ss]' '$config_file'" + run_bash_startup "$start" + [ "$status" -eq 0 ] + [ "$output" = "$start" ] +} + +@test "test-shell-startup-logic: bash enters projects from an unrelated directory" { + mkdir -p "$BATS_TEST_TMPDIR/home/work" "$BATS_TEST_TMPDIR/home/projects" + + run_bash_startup "$BATS_TEST_TMPDIR/home/work" + [ "$status" -eq 0 ] + [ "$output" = "$BATS_TEST_TMPDIR/home/projects" ] +} + +@test "test-shell-startup-logic: bash preserves the VS Code working directory" { + local start="$BATS_TEST_TMPDIR/home/work" + mkdir -p "$start" "$BATS_TEST_TMPDIR/home/projects" + + run_bash_startup "$start" vscode + [ "$status" -eq 0 ] + [ "$output" = "$start" ] +} + +@test "test-shell-startup-logic: bash preserves the working directory when projects is absent" { + local start="$BATS_TEST_TMPDIR/home/work" + mkdir -p "$start" + + run_bash_startup "$start" + [ "$status" -eq 0 ] + [ "$output" = "$start" ] +} + +@test "test-shell-startup-logic: Homebrew completions load only on Bash supporting nosort" { + export TEST_BREW_PREFIX="$BATS_TEST_TMPDIR/brew" + local completions="$TEST_BREW_PREFIX/etc/bash_completion.d" + mkdir -p "$completions" + cat >"$completions/example" <<'EOF' +complete -o nosort -W example example +printf 'completion loaded\n' +EOF + + run env -u BASH_ENV /bin/bash --noprofile --norc -c ' + brew() { printf "%s\n" "$TEST_BREW_PREFIX"; } + source "$REPO_ROOT/home/dot_config/shell/completions.d/00-homebrew.bash" || exit + if (( BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 4) )); then + complete -p example >/dev/null || exit + else + if complete -p example >/dev/null 2>&1; then exit 1; fi + printf "older Bash: completion skipped\n" + fi + ' [ "$status" -eq 0 ] + [[ "$output" = "completion loaded" || "$output" = "older Bash: completion skipped" ]] } @test "test-shell-startup-logic: zsh config contains VS Code check" {