feat: keychain-aware credential helper, array refactor, cleanup

- Detect GCM (Git Credential Manager) as preferred cross-platform helper
- Recognize osxkeychain, GCM, libsecret, gnome-keyring as keychain-backed
- Print distro-specific install hints when no keychain helper found
- Refactor apply_setting_group and apply_ssh_directive_group to use bash
  arrays instead of sed-indexed newline-delimited strings
- Extract get_ssh_directive_value() to deduplicate SSH config parsing
- Fix stale function name in tests (apply_ssh_directive → apply_single_ssh_directive)
- Remove orphan comment in detect_existing_keys
- Bump version to 0.4.0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Flo
2026-04-04 15:00:02 +02:00
parent ca4daa1539
commit 69707b4475
4 changed files with 168 additions and 72 deletions

View File

@@ -4,9 +4,23 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [0.4.0] - 2026-04-04
### Added
- GCM (Git Credential Manager) detection — preferred cross-platform credential helper
- `is_keychain_credential_helper()` recognizes osxkeychain, GCM, libsecret, and gnome-keyring
- Distro-specific install hints when no keychain-backed credential helper is found (Debian/Ubuntu, Fedora/RHEL, Arch, openSUSE, Alpine)
- Audit labels keychain-backed helpers as `(keychain-backed)` for clarity
### Changed
- Harden step skips credential.helper prompt when user already has a keychain-backed helper
- Audit messaging improved: clearer descriptions for missing, insecure, and unknown helpers
- FIDO2 signing wizard, grouped SSH config directives, REASONING.md (prior unreleased work)
## [0.2.3] - 2026-03-31 ## [0.2.3] - 2026-03-31
### Fixed ### Fixed
- Fix e2e.sh distro loop not splitting on spaces (#39)
- FIDO2 key generation on macOS — detect Homebrew's openssh via `ssh-sk-helper` (no freeze), use its `ssh-keygen` binary for hardware key generation - FIDO2 key generation on macOS — detect Homebrew's openssh via `ssh-sk-helper` (no freeze), use its `ssh-keygen` binary for hardware key generation
- Linux gitleaks install hint now shows `apt`/`dnf` instead of `brew` - Linux gitleaks install hint now shows `apt`/`dnf` instead of `brew`
- e2e test runner distro loop broken by `IFS` setting — use bash array - e2e test runner distro loop broken by `IFS` setting — use bash array
@@ -17,6 +31,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [0.2.0] - 2026-03-31 ## [0.2.0] - 2026-03-31
### Added ### Added
- Add REASONING.md documenting trade-offs for each hardening default (#48)
- Gitleaks pre-commit hook installation — creates `~/.config/git/hooks/pre-commit` with `SKIP_GITLEAKS` bypass - Gitleaks pre-commit hook installation — creates `~/.config/git/hooks/pre-commit` with `SKIP_GITLEAKS` bypass
- Global gitignore creation (`~/.config/git/ignore`) with security patterns (`.env`, `*.pem`, `*.key`, credentials, Terraform state) - Global gitignore creation (`~/.config/git/ignore`) with security patterns (`.env`, `*.pem`, `*.key`, credentials, Terraform state)
- Audit of existing global gitignore for missing security patterns - Audit of existing global gitignore for missing security patterns

View File

@@ -139,6 +139,47 @@ The script prints (but does not apply) server/org-level recommendations:
- Clone untrusted repos with `--no-recurse-submodules` - Clone untrusted repos with `--no-recurse-submodules`
- Use separate signing keys per org to prevent cross-platform identity correlation (OSINT) - Use separate signing keys per org to prevent cross-platform identity correlation (OSINT)
## Signing with FIDO2 hardware keys
The script includes an interactive wizard that:
1. Detects existing SSH keys (including custom-named keys from `~/.ssh/config`)
2. Detects FIDO2 hardware (YubiKey, etc.)
3. Offers two tiers:
- **Software SSH key** — use existing `ed25519` or generate one
- **FIDO2 hardware key** — generate `ed25519-sk` with touch-to-sign (if hardware detected)
4. Configures `user.signingkey`, `commit.gpgsign`, `tag.gpgsign`
5. Sets up `~/.config/git/allowed_signers` for local signature verification
These combinations of hardware and OS have been tested:
| Hardware | Firmware | OS | works? |
|----------|----------|----|--------|
| [Yubico Security Key USB C NFC](https://support.yubico.com/s/article/Security-Key-C-NFC) | 5.4.3 | macOS Tahoe | Yes |
| [Yubico Security Key USB C NFC](https://support.yubico.com/s/article/Security-Key-C-NFC) | 5.4.3 | Debian 13 Trixie | |
| [Yubico Security Key USB C NFC](https://support.yubico.com/s/article/Security-Key-C-NFC) | 5.4.3 | Fedora 42 | Yes |
| [Yubico Security Key USB A NFC](https://support.yubico.com/s/article/Security-Key-NFC) | 5.4.3 | macOS Tahoe | Yes |
| [Yubico Security Key USB A NFC](https://support.yubico.com/s/article/Security-Key-NFC) | 5.4.3 | Debian 13 Trixie | |
| [Yubico Security Key USB A NFC](https://support.yubico.com/s/article/Security-Key-NFC) | 5.4.3 | Fedora 42 | Yes |
| [Yubico Security Key USB A NFC](https://www.yubico.com/products/security-key-by-yubico/usb-a-nfc/) | 5.0.2 | macOS Tahoe | Yes |
| [Yubico Security Key USB A NFC](https://www.yubico.com/products/security-key-by-yubico/usb-a-nfc/) | 5.0.2 | Debian 13 Trixie | |
| [Yubico Security Key USB A NFC](https://www.yubico.com/products/security-key-by-yubico/usb-a-nfc/) | 5.0.2 | Fedora 42 | Yes |
| [Yubico YubiKey 5C nano](https://support.yubico.com/s/article/YubiKey-5C-Nano) | 5.4.3 | macOS Tahoe | Yes |
| [Yubico YubiKey 5C nano](https://support.yubico.com/s/article/YubiKey-5C-Nano) | 5.4.3 | Debian 13 Trixie | |
| [Yubico YubiKey 5C nano](https://support.yubico.com/s/article/YubiKey-5C-Nano) | 5.4.3 | Fedora 42 | Yes |
| [Yubico YubiKey 5 NFC](https://support.yubico.com/s/article/YubiKey-5-NFC) | 5.1.2 | macOS Tahoe | Yes* |
| [Yubico YubiKey 5 NFC](https://support.yubico.com/s/article/YubiKey-5-NFC) | 5.1.2 | Debian 13 Trixie| |
| [Yubico YubiKey 5 NFC](https://support.yubico.com/s/article/YubiKey-5-NFC) | 5.1.2 | Fedora 42| Yes* |
| [SoloKeys Solo 1 Tap USB-A](https://solokeys.com/collections/all/products/solo-tap-usb-a-preorder) | | Ubuntu 24.04 | Yes |
| [SoloKeys Solo 1 Tap USB-A](https://solokeys.com/collections/all/products/solo-tap-usb-a-preorder) | | Debian 13 Trixie | Yes |
| [SoloKeys Solo 1 Tap USB-A](https://solokeys.com/collections/all/products/solo-tap-usb-a-preorder) | | Fedora 42 | Yes |
| [SoloKeys Solo 1 Tap USB-A](https://solokeys.com/collections/all/products/solo-tap-usb-a-preorder) | | macOS Tahoe | Yes |
| [HYPERSECU HyperFIDO mini](https://033c2a7e-e1da-473d-a255-6132a1d3aa6e.filesusr.com/ugd/5aae8d_f4e8a196a99f45b1859e201a7cb40962.pdf) | | macOS Tahoe | Yes |
| [HYPERSECU HyperFIDO mini](https://033c2a7e-e1da-473d-a255-6132a1d3aa6e.filesusr.com/ugd/5aae8d_f4e8a196a99f45b1859e201a7cb40962.pdf) | | Ubuntu 24.04 | Yes |
| [HYPERSECU HyperFIDO mini](https://033c2a7e-e1da-473d-a255-6132a1d3aa6e.filesusr.com/ugd/5aae8d_f4e8a196a99f45b1859e201a7cb40962.pdf) | | Debian 13 Trixie | |
| [HYPERSECU HyperFIDO mini](https://033c2a7e-e1da-473d-a255-6132a1d3aa6e.filesusr.com/ugd/5aae8d_f4e8a196a99f45b1859e201a7cb40962.pdf) | | Fedora 42 | |
## Running Tests ## Running Tests
```bash ```bash

View File

@@ -10,7 +10,7 @@ IFS=$'\n\t'
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Constants # Constants
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
readonly VERSION="0.3.1" readonly VERSION="0.4.0"
readonly BACKUP_DIR="${HOME}/.config/git" readonly BACKUP_DIR="${HOME}/.config/git"
readonly HOOKS_DIR="${HOME}/.config/git/hooks" readonly HOOKS_DIR="${HOME}/.config/git/hooks"
readonly ALLOWED_SIGNERS_FILE="${HOME}/.config/git/allowed_signers" readonly ALLOWED_SIGNERS_FILE="${HOME}/.config/git/allowed_signers"
@@ -266,13 +266,31 @@ check_dependencies() {
detect_credential_helper detect_credential_helper
} }
# Check if a credential.helper value corresponds to a keychain-backed store.
# Returns 0 (true) if the helper stores credentials in the OS keychain.
is_keychain_credential_helper() {
local helper="$1"
case "$helper" in
osxkeychain|manager|manager-core) return 0 ;;
*git-credential-libsecret*) return 0 ;;
*git-credential-gnome-keyring*) return 0 ;;
*) return 1 ;;
esac
}
detect_credential_helper() { detect_credential_helper() {
# Git Credential Manager (GCM) — cross-platform, preferred when available
if command -v git-credential-manager >/dev/null 2>&1; then
DETECTED_CRED_HELPER="manager"
return
fi
case "$PLATFORM" in case "$PLATFORM" in
macos) macos)
DETECTED_CRED_HELPER="osxkeychain" DETECTED_CRED_HELPER="osxkeychain"
;; ;;
linux) linux)
# Try to find libsecret credential helper # Try libsecret (GNOME Keyring / KDE Wallet / any Secret Service provider)
local libsecret_path="" local libsecret_path=""
for path in \ for path in \
/usr/lib/git-core/git-credential-libsecret \ /usr/lib/git-core/git-credential-libsecret \
@@ -286,10 +304,49 @@ detect_credential_helper() {
if [ -n "$libsecret_path" ]; then if [ -n "$libsecret_path" ]; then
DETECTED_CRED_HELPER="$libsecret_path" DETECTED_CRED_HELPER="$libsecret_path"
else return
DETECTED_CRED_HELPER="cache --timeout=3600"
print_info "libsecret not found; falling back to in-memory credential cache (1h TTL, not persistent)"
fi fi
# Fallback: in-memory cache (not persistent across reboots)
DETECTED_CRED_HELPER="cache --timeout=3600"
print_info "No keychain-backed credential helper found; falling back to in-memory cache (1h TTL)"
credential_install_hint
;;
esac
}
# Print distro-specific install hints for keychain credential storage.
credential_install_hint() {
local distro_id=""
if [ -f /etc/os-release ]; then
# shellcheck disable=SC1091 # os-release is a system file, not part of this project
distro_id="$(. /etc/os-release && printf '%s' "${ID:-}")"
fi
printf ' %bTo store credentials in the OS keychain, install one of:%b\n' "$YELLOW" "$RESET" >&2
case "$distro_id" in
ubuntu|debian|pop|linuxmint)
printf ' • libsecret: sudo apt install libsecret-1-dev git make && cd /usr/share/doc/git/contrib/credential/libsecret && sudo make\n' >&2
printf ' • GCM: https://github.com/git-ecosystem/git-credential-manager/releases\n' >&2
;;
fedora|rhel|centos|rocky|alma)
printf ' • libsecret: sudo dnf install git-credential-libsecret\n' >&2
printf ' • GCM: https://github.com/git-ecosystem/git-credential-manager/releases\n' >&2
;;
arch|manjaro|endeavouros)
printf ' • libsecret: sudo pacman -S libsecret\n' >&2
printf ' • GCM: https://github.com/git-ecosystem/git-credential-manager/releases\n' >&2
;;
opensuse*|suse*)
printf ' • libsecret: sudo zypper install git-credential-libsecret\n' >&2
printf ' • GCM: https://github.com/git-ecosystem/git-credential-manager/releases\n' >&2
;;
alpine)
printf ' • GCM: https://github.com/git-ecosystem/git-credential-manager/releases\n' >&2
;;
*)
printf ' • libsecret: install git-credential-libsecret via your package manager\n' >&2
printf ' • GCM: https://github.com/git-ecosystem/git-credential-manager/releases\n' >&2
;; ;;
esac esac
} }
@@ -387,14 +444,15 @@ audit_git_config() {
local cred_current local cred_current
cred_current="$(git config --global --get credential.helper 2>/dev/null || true)" cred_current="$(git config --global --get credential.helper 2>/dev/null || true)"
if [ -z "$cred_current" ]; then if [ -z "$cred_current" ]; then
print_miss "credential.helper (expected: $DETECTED_CRED_HELPER)" print_miss "credential.helper not set (credentials won't be cached)"
elif [ "$cred_current" = "store" ]; then elif [ "$cred_current" = "store" ]; then
print_warn "credential.helper = store (INSECURE: stores passwords in plaintext; expected: $DETECTED_CRED_HELPER)" print_warn "credential.helper = store (INSECURE: stores passwords in plaintext ~/${cred_current})"
elif is_keychain_credential_helper "$cred_current"; then
print_ok "credential.helper = $cred_current (keychain-backed)"
elif [ "$cred_current" = "$DETECTED_CRED_HELPER" ]; then elif [ "$cred_current" = "$DETECTED_CRED_HELPER" ]; then
print_ok "credential.helper = $cred_current" print_ok "credential.helper = $cred_current"
else else
# Non-store, non-recommended — could be user's custom helper print_warn "credential.helper = $cred_current (not a known keychain-backed helper)"
print_warn "credential.helper = $cred_current (expected: $DETECTED_CRED_HELPER)"
fi fi
print_header "Defaults" print_header "Defaults"
@@ -703,22 +761,22 @@ apply_setting_group() {
shift 2 shift 2
# Collect pending changes (settings that need updating) # Collect pending changes (settings that need updating)
local pending_keys="" local pending_keys=()
local pending_vals="" local pending_vals=()
local pending_explanations="" local pending_explanations=()
local count=0
while [ $# -ge 3 ]; do while [ $# -ge 3 ]; do
local key="$1" value="$2" explanation="$3" local key="$1" value="$2" explanation="$3"
shift 3 shift 3
if setting_needs_change "$key" "$value"; then if setting_needs_change "$key" "$value"; then
pending_keys="${pending_keys}${key}"$'\n' pending_keys+=("$key")
pending_vals="${pending_vals}${value}"$'\n' pending_vals+=("$value")
pending_explanations="${pending_explanations}${explanation}"$'\n' pending_explanations+=("$explanation")
count=$((count + 1))
fi fi
done done
local count="${#pending_keys[@]}"
# Nothing to do # Nothing to do
if [ "$count" -eq 0 ]; then if [ "$count" -eq 0 ]; then
return 0 return 0
@@ -728,25 +786,15 @@ apply_setting_group() {
printf ' %s\n\n' "$description" >&2 printf ' %s\n\n' "$description" >&2
# Show what will change # Show what will change
local i=0 local i
while [ "$i" -lt "$count" ]; do for ((i = 0; i < count; i++)); do
local key val expl printf ' %-40s %s\n' "${pending_keys[$i]} = ${pending_vals[$i]}" "# ${pending_explanations[$i]}" >&2
key="$(printf '%s' "$pending_keys" | sed -n "$((i + 1))p")"
val="$(printf '%s' "$pending_vals" | sed -n "$((i + 1))p")"
expl="$(printf '%s' "$pending_explanations" | sed -n "$((i + 1))p")"
printf ' %-40s %s\n' "${key} = ${val}" "# ${expl}" >&2
i=$((i + 1))
done done
printf '\n' >&2 printf '\n' >&2
if prompt_yn "Apply these ${count} settings?"; then if prompt_yn "Apply these ${count} settings?"; then
i=0 for ((i = 0; i < count; i++)); do
while [ "$i" -lt "$count" ]; do git config --global "${pending_keys[$i]}" "${pending_vals[$i]}"
local key val
key="$(printf '%s' "$pending_keys" | sed -n "$((i + 1))p")"
val="$(printf '%s' "$pending_vals" | sed -n "$((i + 1))p")"
git config --global "$key" "$val"
i=$((i + 1))
done done
print_info "Applied ${count} settings" print_info "Applied ${count} settings"
fi fi
@@ -841,8 +889,10 @@ apply_git_config() {
"init.defaultBranch" "main" "Default branch name for new repos" \ "init.defaultBranch" "main" "Default branch name for new repos" \
"log.showSignature" "true" "Show signature status in git log" "log.showSignature" "true" "Show signature status in git log"
# Credential helper needs special logic (warn about 'store') # Credential helper needs special logic — accept any keychain-backed helper
if [ "$cred_current" != "$DETECTED_CRED_HELPER" ]; then if is_keychain_credential_helper "$cred_current" 2>/dev/null; then
: # Already using a keychain-backed helper — leave it alone
elif [ "$cred_current" != "$DETECTED_CRED_HELPER" ]; then
local cred_prompt="Set credential.helper = $DETECTED_CRED_HELPER?" local cred_prompt="Set credential.helper = $DETECTED_CRED_HELPER?"
if [ "$cred_current" = "store" ]; then if [ "$cred_current" = "store" ]; then
cred_prompt="Replace INSECURE credential.helper=store with $DETECTED_CRED_HELPER?" cred_prompt="Replace INSECURE credential.helper=store with $DETECTED_CRED_HELPER?"
@@ -1021,8 +1071,6 @@ detect_existing_keys() {
if [ -f "$expanded_key" ]; then if [ -f "$expanded_key" ]; then
SIGNING_KEY_FOUND=true SIGNING_KEY_FOUND=true
SIGNING_PUB_PATH="$expanded_key" SIGNING_PUB_PATH="$expanded_key"
# Derive private key path (remove .pub suffix if present)
return return
fi fi
fi fi
@@ -1552,15 +1600,18 @@ setup_allowed_signers() {
# SSH config hardening # SSH config hardening
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Read the current value of an SSH config directive (empty if absent).
get_ssh_directive_value() {
local directive="$1"
local raw
raw="$(grep -i "^[[:space:]]*${directive}[[:space:]=]" "$SSH_CONFIG" 2>/dev/null | head -1 | sed 's/^[[:space:]]*[^[:space:]=]*[[:space:]=]*//' || true)"
strip_ssh_value "$raw"
}
ssh_directive_needs_change() { ssh_directive_needs_change() {
local directive="$1" local directive="$1"
local value="$2" local value="$2"
[ "$(get_ssh_directive_value "$directive")" != "$value" ]
local current
current="$(grep -i "^[[:space:]]*${directive}[[:space:]=]" "$SSH_CONFIG" 2>/dev/null | head -1 | sed 's/^[[:space:]]*[^[:space:]=]*[[:space:]=]*//' || true)"
current="$(strip_ssh_value "$current")"
[ "$current" != "$value" ]
} }
apply_single_ssh_directive() { apply_single_ssh_directive() {
@@ -1568,8 +1619,7 @@ apply_single_ssh_directive() {
local value="$2" local value="$2"
local current local current
current="$(grep -i "^[[:space:]]*${directive}[[:space:]=]" "$SSH_CONFIG" 2>/dev/null | head -1 | sed 's/^[[:space:]]*[^[:space:]=]*[[:space:]=]*//' || true)" current="$(get_ssh_directive_value "$directive")"
current="$(strip_ssh_value "$current")"
if [ -n "$current" ]; then if [ -n "$current" ]; then
# Replace existing directive # Replace existing directive
@@ -1597,22 +1647,22 @@ apply_ssh_directive_group() {
shift 2 shift 2
# Collect pending changes (directives that need updating) # Collect pending changes (directives that need updating)
local pending_keys="" local pending_keys=()
local pending_vals="" local pending_vals=()
local pending_explanations="" local pending_explanations=()
local count=0
while [ $# -ge 3 ]; do while [ $# -ge 3 ]; do
local key="$1" value="$2" explanation="$3" local key="$1" value="$2" explanation="$3"
shift 3 shift 3
if ssh_directive_needs_change "$key" "$value"; then if ssh_directive_needs_change "$key" "$value"; then
pending_keys="${pending_keys}${key}"$'\n' pending_keys+=("$key")
pending_vals="${pending_vals}${value}"$'\n' pending_vals+=("$value")
pending_explanations="${pending_explanations}${explanation}"$'\n' pending_explanations+=("$explanation")
count=$((count + 1))
fi fi
done done
local count="${#pending_keys[@]}"
if [ "$count" -eq 0 ]; then if [ "$count" -eq 0 ]; then
return 0 return 0
fi fi
@@ -1620,25 +1670,15 @@ apply_ssh_directive_group() {
printf '\n %b%s%b\n' "$BOLD" "$group_name" "$RESET" >&2 printf '\n %b%s%b\n' "$BOLD" "$group_name" "$RESET" >&2
printf ' %s\n\n' "$description" >&2 printf ' %s\n\n' "$description" >&2
local i=0 local i
while [ "$i" -lt "$count" ]; do for ((i = 0; i < count; i++)); do
local key val expl printf ' %-45s %s\n' "${pending_keys[$i]} ${pending_vals[$i]}" "# ${pending_explanations[$i]}" >&2
key="$(printf '%s' "$pending_keys" | sed -n "$((i + 1))p")"
val="$(printf '%s' "$pending_vals" | sed -n "$((i + 1))p")"
expl="$(printf '%s' "$pending_explanations" | sed -n "$((i + 1))p")"
printf ' %-45s %s\n' "${key} ${val}" "# ${expl}" >&2
i=$((i + 1))
done done
printf '\n' >&2 printf '\n' >&2
if prompt_yn "Apply these ${count} directives?"; then if prompt_yn "Apply these ${count} directives?"; then
i=0 for ((i = 0; i < count; i++)); do
while [ "$i" -lt "$count" ]; do apply_single_ssh_directive "${pending_keys[$i]}" "${pending_vals[$i]}"
local key val
key="$(printf '%s' "$pending_keys" | sed -n "$((i + 1))p")"
val="$(printf '%s' "$pending_vals" | sed -n "$((i + 1))p")"
apply_single_ssh_directive "$key" "$val"
i=$((i + 1))
done done
print_info "Applied ${count} SSH directives" print_info "Applied ${count} SSH directives"
fi fi

View File

@@ -497,7 +497,7 @@ SSHEOF
source_functions source_functions
AUTO_YES=true AUTO_YES=true
apply_ssh_directive "StrictHostKeyChecking" "accept-new" apply_single_ssh_directive "StrictHostKeyChecking" "accept-new"
# Should still have exactly one occurrence # Should still have exactly one occurrence
local count local count
@@ -515,7 +515,7 @@ SSHEOF
source_functions source_functions
AUTO_YES=true AUTO_YES=true
apply_ssh_directive "StrictHostKeyChecking" "accept-new" apply_single_ssh_directive "StrictHostKeyChecking" "accept-new"
# Verify updated # Verify updated
grep -q "StrictHostKeyChecking accept-new" "${TEST_HOME}/.ssh/config" grep -q "StrictHostKeyChecking accept-new" "${TEST_HOME}/.ssh/config"
@@ -548,7 +548,7 @@ SSHEOF
source_functions source_functions
AUTO_YES=true AUTO_YES=true
apply_ssh_directive "StrictHostKeyChecking" "accept-new" apply_single_ssh_directive "StrictHostKeyChecking" "accept-new"
# Should still have exactly one occurrence # Should still have exactly one occurrence
local count local count
@@ -1156,7 +1156,7 @@ EOF
# v0.2.0: Version bump # v0.2.0: Version bump
# =========================================================================== # ===========================================================================
@test "--version reports 0.2.3" { @test "--version reports 0.4.0" {
run bash "$SCRIPT" --version run bash "$SCRIPT" --version
assert_output --partial "0.2.3" assert_output --partial "0.4.0"
} }