diff --git a/CHANGELOG.md b/CHANGELOG.md index 5494149..5ae0bc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/). +## [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 ### 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 - Linux gitleaks install hint now shows `apt`/`dnf` instead of `brew` - 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 ### 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 - Global gitignore creation (`~/.config/git/ignore`) with security patterns (`.env`, `*.pem`, `*.key`, credentials, Terraform state) - Audit of existing global gitignore for missing security patterns diff --git a/README.md b/README.md index 5b74836..22ab0c9 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,47 @@ The script prints (but does not apply) server/org-level recommendations: - Clone untrusted repos with `--no-recurse-submodules` - 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 ```bash diff --git a/git-harden.sh b/git-harden.sh index ec19975..76fcf21 100755 --- a/git-harden.sh +++ b/git-harden.sh @@ -10,7 +10,7 @@ IFS=$'\n\t' # ------------------------------------------------------------------------------ # Constants # ------------------------------------------------------------------------------ -readonly VERSION="0.3.1" +readonly VERSION="0.4.0" readonly BACKUP_DIR="${HOME}/.config/git" readonly HOOKS_DIR="${HOME}/.config/git/hooks" readonly ALLOWED_SIGNERS_FILE="${HOME}/.config/git/allowed_signers" @@ -266,13 +266,31 @@ check_dependencies() { 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() { + # 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 macos) DETECTED_CRED_HELPER="osxkeychain" ;; linux) - # Try to find libsecret credential helper + # Try libsecret (GNOME Keyring / KDE Wallet / any Secret Service provider) local libsecret_path="" for path in \ /usr/lib/git-core/git-credential-libsecret \ @@ -286,10 +304,49 @@ detect_credential_helper() { if [ -n "$libsecret_path" ]; then DETECTED_CRED_HELPER="$libsecret_path" - else - DETECTED_CRED_HELPER="cache --timeout=3600" - print_info "libsecret not found; falling back to in-memory credential cache (1h TTL, not persistent)" + return 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 } @@ -387,14 +444,15 @@ audit_git_config() { local cred_current cred_current="$(git config --global --get credential.helper 2>/dev/null || true)" 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 - 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 print_ok "credential.helper = $cred_current" else - # Non-store, non-recommended — could be user's custom helper - print_warn "credential.helper = $cred_current (expected: $DETECTED_CRED_HELPER)" + print_warn "credential.helper = $cred_current (not a known keychain-backed helper)" fi print_header "Defaults" @@ -703,22 +761,22 @@ apply_setting_group() { shift 2 # Collect pending changes (settings that need updating) - local pending_keys="" - local pending_vals="" - local pending_explanations="" - local count=0 + local pending_keys=() + local pending_vals=() + local pending_explanations=() while [ $# -ge 3 ]; do local key="$1" value="$2" explanation="$3" shift 3 if setting_needs_change "$key" "$value"; then - pending_keys="${pending_keys}${key}"$'\n' - pending_vals="${pending_vals}${value}"$'\n' - pending_explanations="${pending_explanations}${explanation}"$'\n' - count=$((count + 1)) + pending_keys+=("$key") + pending_vals+=("$value") + pending_explanations+=("$explanation") fi done + local count="${#pending_keys[@]}" + # Nothing to do if [ "$count" -eq 0 ]; then return 0 @@ -728,25 +786,15 @@ apply_setting_group() { printf ' %s\n\n' "$description" >&2 # Show what will change - local i=0 - while [ "$i" -lt "$count" ]; do - local key val expl - 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)) + local i + for ((i = 0; i < count; i++)); do + printf ' %-40s %s\n' "${pending_keys[$i]} = ${pending_vals[$i]}" "# ${pending_explanations[$i]}" >&2 done printf '\n' >&2 if prompt_yn "Apply these ${count} settings?"; then - i=0 - while [ "$i" -lt "$count" ]; do - 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)) + for ((i = 0; i < count; i++)); do + git config --global "${pending_keys[$i]}" "${pending_vals[$i]}" done print_info "Applied ${count} settings" fi @@ -841,8 +889,10 @@ apply_git_config() { "init.defaultBranch" "main" "Default branch name for new repos" \ "log.showSignature" "true" "Show signature status in git log" - # Credential helper needs special logic (warn about 'store') - if [ "$cred_current" != "$DETECTED_CRED_HELPER" ]; then + # Credential helper needs special logic — accept any keychain-backed helper + 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?" if [ "$cred_current" = "store" ]; then cred_prompt="Replace INSECURE credential.helper=store with $DETECTED_CRED_HELPER?" @@ -1021,8 +1071,6 @@ detect_existing_keys() { if [ -f "$expanded_key" ]; then SIGNING_KEY_FOUND=true SIGNING_PUB_PATH="$expanded_key" - # Derive private key path (remove .pub suffix if present) - return fi fi @@ -1552,15 +1600,18 @@ setup_allowed_signers() { # 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() { local directive="$1" local value="$2" - - 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" ] + [ "$(get_ssh_directive_value "$directive")" != "$value" ] } apply_single_ssh_directive() { @@ -1568,8 +1619,7 @@ apply_single_ssh_directive() { local value="$2" 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="$(get_ssh_directive_value "$directive")" if [ -n "$current" ]; then # Replace existing directive @@ -1597,22 +1647,22 @@ apply_ssh_directive_group() { shift 2 # Collect pending changes (directives that need updating) - local pending_keys="" - local pending_vals="" - local pending_explanations="" - local count=0 + local pending_keys=() + local pending_vals=() + local pending_explanations=() while [ $# -ge 3 ]; do local key="$1" value="$2" explanation="$3" shift 3 if ssh_directive_needs_change "$key" "$value"; then - pending_keys="${pending_keys}${key}"$'\n' - pending_vals="${pending_vals}${value}"$'\n' - pending_explanations="${pending_explanations}${explanation}"$'\n' - count=$((count + 1)) + pending_keys+=("$key") + pending_vals+=("$value") + pending_explanations+=("$explanation") fi done + local count="${#pending_keys[@]}" + if [ "$count" -eq 0 ]; then return 0 fi @@ -1620,25 +1670,15 @@ apply_ssh_directive_group() { printf '\n %b%s%b\n' "$BOLD" "$group_name" "$RESET" >&2 printf ' %s\n\n' "$description" >&2 - local i=0 - while [ "$i" -lt "$count" ]; do - local key val expl - 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)) + local i + for ((i = 0; i < count; i++)); do + printf ' %-45s %s\n' "${pending_keys[$i]} ${pending_vals[$i]}" "# ${pending_explanations[$i]}" >&2 done printf '\n' >&2 if prompt_yn "Apply these ${count} directives?"; then - i=0 - while [ "$i" -lt "$count" ]; do - 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)) + for ((i = 0; i < count; i++)); do + apply_single_ssh_directive "${pending_keys[$i]}" "${pending_vals[$i]}" done print_info "Applied ${count} SSH directives" fi diff --git a/test/git-harden.bats b/test/git-harden.bats index b7a0018..3518c42 100755 --- a/test/git-harden.bats +++ b/test/git-harden.bats @@ -497,7 +497,7 @@ SSHEOF source_functions AUTO_YES=true - apply_ssh_directive "StrictHostKeyChecking" "accept-new" + apply_single_ssh_directive "StrictHostKeyChecking" "accept-new" # Should still have exactly one occurrence local count @@ -515,7 +515,7 @@ SSHEOF source_functions AUTO_YES=true - apply_ssh_directive "StrictHostKeyChecking" "accept-new" + apply_single_ssh_directive "StrictHostKeyChecking" "accept-new" # Verify updated grep -q "StrictHostKeyChecking accept-new" "${TEST_HOME}/.ssh/config" @@ -548,7 +548,7 @@ SSHEOF source_functions AUTO_YES=true - apply_ssh_directive "StrictHostKeyChecking" "accept-new" + apply_single_ssh_directive "StrictHostKeyChecking" "accept-new" # Should still have exactly one occurrence local count @@ -1156,7 +1156,7 @@ EOF # v0.2.0: Version bump # =========================================================================== -@test "--version reports 0.2.3" { +@test "--version reports 0.4.0" { run bash "$SCRIPT" --version - assert_output --partial "0.2.3" + assert_output --partial "0.4.0" }