4 Commits
v0.3.0 ... main

Author SHA1 Message Date
Flo
cd2afdb308 feat: tests, device-not-found retry, remove Qubes CTAP2 warning
Add 20 BATS tests and 1 interactive test for v0.5.0 edge-case
fixes. FIDO2 keygen now prompts to retry on "device not found"
instead of exiting. Remove stale Qubes vhci_hcd warning. Update
hardware test matrix in README.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 04:22:52 -07:00
Flo
c5bbe5b44a feat: UX hardening for edge cases and pre-existing configurations
Guard user.useConfigOnly behind identity check, offer to unset
conflicting pull.rebase, use dedicated signing key names to avoid
colliding with auth keys, back up SSH config before changes, place
new SSH directives in Host * blocks, and prompt for email in
allowed_signers setup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 03:25:48 -07:00
Flo
69707b4475 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>
2026-04-04 15:00:02 +02:00
Flo
ca4daa1539 feat: group SSH config directives with trade-off explanations
SSH config hardening now presents directives in logical groups
(matching the git config UX) with reasoning for each:
- Host Verification: TOFU rationale, known_hosts exfiltration risk
- Key & Agent Management: key enumeration attack, passphrase fatigue
- Algorithm Restrictions: downgrade attack, intentional RSA breakage

Each group batches its directives into a single prompt instead of
asking one-by-one.

Bump version to 0.3.1.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 09:14:57 -07:00
7 changed files with 872 additions and 132 deletions

View File

@@ -4,9 +4,51 @@ 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.5.0] - 2026-04-05
### Added
- Identity guard: prompt for `user.name`/`user.email` before enabling `user.useConfigOnly=true` to prevent commit lockout
- Apply phase offers to unset `pull.rebase` when it conflicts with `pull.ff=only`
- SSH config backup (`~/.ssh/config.pre-harden-*`) before applying SSH directives
- `core.hooksPath` gets its own prompt with explicit warning about overriding per-repo hooks (husky, lefthook, pre-commit)
- Allowed signers setup prompts for email when `user.email` is not configured globally
### Changed
- Signing keys use dedicated names (`id_ed25519_signing`, `id_ed25519_sk_signing`, `id_ecdsa_sk_signing`) to avoid colliding with existing authentication keys
- "Key already exists" messages changed from `[WARN]` to `[INFO]` with clearer guidance ("using existing key")
- New SSH directives are placed inside a `Host *` block instead of appended bare to EOF
- `--reset-signing` now cleans the actual configured `user.signingkey` path in addition to well-known key names
### Removed
- Qubes OS CTAP2/vhci_hcd warning (PIN-protected keys work over USB passthrough)
### Fixed
- `readonly VERSION` variable conflict when sourcing `/etc/os-release` (replaced `.` with `sed` parse)
- FIDO2 key generation now offers retry when security key is not plugged in ("device not found")
- Admin recommendations suppressed when signing setup was skipped or failed
### Tests
- 20 new BATS tests (112 total) covering identity guard, pull.rebase unset, SSH `Host *` placement, SSH config backup, dedicated signing key names, core.hooksPath separation, reset-signing with configured paths
- New interactive test: identity guard flow (missing name/email prompts)
- Updated existing tests for dedicated signing key names and inter-test isolation
## [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 +59,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 | Yes |
| [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| Yes |
| [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 | Yes |
## Running Tests ## Running Tests
```bash ```bash

View File

@@ -10,7 +10,7 @@ IFS=$'\n\t'
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Constants # Constants
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
readonly VERSION="0.3.0" readonly VERSION="0.5.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,48 @@ 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
distro_id="$(sed -n 's/^ID=//p' /etc/os-release | tr -d '"')"
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
} }
@@ -321,6 +377,14 @@ audit_git_config() {
print_header "Identity" print_header "Identity"
audit_git_setting "user.useConfigOnly" "true" audit_git_setting "user.useConfigOnly" "true"
# Warn if useConfigOnly would lock out commits (no global identity)
local has_name has_email
has_name="$(git config --global --get user.name 2>/dev/null || true)"
has_email="$(git config --global --get user.email 2>/dev/null || true)"
if [[ -z "$has_name" || -z "$has_email" ]]; then
print_warn "user.name/user.email not set globally — useConfigOnly=true will block commits outside configured repos"
fi
print_header "Object Integrity" print_header "Object Integrity"
audit_git_setting "transfer.fsckObjects" "true" audit_git_setting "transfer.fsckObjects" "true"
audit_git_setting "fetch.fsckObjects" "true" audit_git_setting "fetch.fsckObjects" "true"
@@ -387,14 +451,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 +768,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 +793,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
@@ -783,10 +838,24 @@ apply_git_config() {
"core.protectNTFS" "true" "Block NTFS 8.3 short-name attacks" \ "core.protectNTFS" "true" "Block NTFS 8.3 short-name attacks" \
"core.protectHFS" "true" "Block HFS+ Unicode normalization attacks" \ "core.protectHFS" "true" "Block HFS+ Unicode normalization attacks" \
"core.fsmonitor" "false" "Disable filesystem monitor (attack surface)" \ "core.fsmonitor" "false" "Disable filesystem monitor (attack surface)" \
"core.hooksPath" "$hooks_path_val" "Redirect hooks to central dir" \
"safe.bareRepository" "explicit" "Require --git-dir for bare repos" \ "safe.bareRepository" "explicit" "Require --git-dir for bare repos" \
"submodule.recurse" "false" "Don't auto-recurse into submodules" "submodule.recurse" "false" "Don't auto-recurse into submodules"
# core.hooksPath: separate prompt — this overrides ALL per-repo hooks
if setting_needs_change "core.hooksPath" "$hooks_path_val"; then
print_header "Global Hooks Path"
printf ' %bWarning:%b Setting core.hooksPath redirects ALL hook execution to a\n' "$YELLOW" "$RESET" >&2
printf ' central directory. Per-repo hooks (.git/hooks/) will stop running.\n' >&2
printf ' This includes hooks from frameworks like husky, lefthook, and pre-commit.\n\n' >&2
printf ' Recommended: set this, then install a dispatch hook that calls per-repo\n' >&2
printf ' hooks when present (this script installs a gitleaks hook there).\n\n' >&2
printf ' core.hooksPath = %s\n\n' "$hooks_path_val" >&2
if prompt_yn "Set core.hooksPath? (overrides per-repo hooks)"; then
git config --global core.hooksPath "$hooks_path_val"
print_info "Set core.hooksPath = $hooks_path_val"
fi
fi
# core.symlinks: interactive-only (may break symlink-dependent workflows) # core.symlinks: interactive-only (may break symlink-dependent workflows)
if [ "$AUTO_YES" = false ]; then if [ "$AUTO_YES" = false ]; then
local current_symlinks local current_symlinks
@@ -831,18 +900,76 @@ apply_git_config() {
fi fi
fi fi
# pull.rebase conflicts with pull.ff=only — offer to unset
local pull_rebase
pull_rebase="$(git config --global --get pull.rebase 2>/dev/null || true)"
if [[ -n "$pull_rebase" ]]; then
printf '\n %bpull.rebase = %s conflicts with pull.ff = only%b\n' "$YELLOW" "$pull_rebase" "$RESET" >&2
printf ' With pull.ff=only, git already refuses non-fast-forward pulls.\n' >&2
printf ' Having pull.rebase set alongside it causes confusing errors.\n\n' >&2
if prompt_yn "Unset pull.rebase?"; then
git config --global --unset pull.rebase
print_info "Unset pull.rebase"
fi
fi
# --- Group 5: Credential, Identity & Defaults --- # --- Group 5: Credential, Identity & Defaults ---
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)"
apply_setting_group "Identity, Credentials & Defaults" \ apply_setting_group "Defaults & Visibility" \
"Prevent accidental identity, enforce secure credential storage." \ "Sensible defaults for new repositories and log output." \
"user.useConfigOnly" "true" "Block commits without explicit user.name/email" \
"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') # user.useConfigOnly needs a guard — it locks out commits without identity
if [ "$cred_current" != "$DETECTED_CRED_HELPER" ]; then if setting_needs_change "user.useConfigOnly" "true"; then
local has_name has_email
has_name="$(git config --global --get user.name 2>/dev/null || true)"
has_email="$(git config --global --get user.email 2>/dev/null || true)"
if [[ -z "$has_name" || -z "$has_email" ]]; then
print_header "Identity Guard"
printf ' %buseConfigOnly=true blocks commits without user.name and user.email.%b\n' "$YELLOW" "$RESET" >&2
printf ' You are missing: %s\n\n' \
"$( [[ -z "$has_name" ]] && printf 'user.name '; [[ -z "$has_email" ]] && printf 'user.email' )" >&2
if [[ -z "$has_name" ]]; then
printf ' Enter your name (or press Enter to skip): ' >&2
local input_name
read -r input_name </dev/tty || input_name=""
if [[ -n "$input_name" ]]; then
git config --global user.name "$input_name"
print_info "Set user.name = $input_name"
has_name="$input_name"
fi
fi
if [[ -z "$has_email" ]]; then
printf ' Enter your email (or press Enter to skip): ' >&2
local input_email
read -r input_email </dev/tty || input_email=""
if [[ -n "$input_email" ]]; then
git config --global user.email "$input_email"
print_info "Set user.email = $input_email"
has_email="$input_email"
fi
fi
if [[ -z "$has_name" || -z "$has_email" ]]; then
print_warn "Skipping user.useConfigOnly — set user.name and user.email first to avoid being locked out"
else
git config --global user.useConfigOnly true
print_info "Set user.useConfigOnly = true"
fi
else
if prompt_yn "Set user.useConfigOnly = true? (block commits without explicit identity)"; then
git config --global user.useConfigOnly true
print_info "Set user.useConfigOnly = true"
fi
fi
fi
# 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?" 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,15 +1148,13 @@ 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
# Check common ed25519 key locations (sk first, then software) # Check common ed25519 key locations (dedicated signing keys first, then general)
local priv_path pub_path local priv_path pub_path
for key_type in id_ed25519_sk id_ed25519; do for key_type in id_ed25519_sk_signing id_ecdsa_sk_signing id_ed25519_signing id_ed25519_sk id_ed25519; do
priv_path="${SSH_DIR}/${key_type}" priv_path="${SSH_DIR}/${key_type}"
pub_path="${priv_path}.pub" pub_path="${priv_path}.pub"
if [ -f "$pub_path" ]; then if [ -f "$pub_path" ]; then
@@ -1182,14 +1307,35 @@ reset_signing() {
print_info "No signing key in git config" print_info "No signing key in git config"
fi fi
# Collect all signing key files that would block fresh generation # Collect signing key files: start with the actual configured path (if any),
# then check well-known names (dedicated signing keys + legacy defaults)
local key_files=() local key_files=()
local candidate local candidate
local seen_paths=""
# Include the actual configured key and its private counterpart
if [[ -n "$signing_key" ]]; then
local configured_path="${signing_key/#\~/$HOME}"
for candidate in "$configured_path" "${configured_path%.pub}"; do
if [[ -f "$candidate" ]] && [[ "$seen_paths" != *"|${candidate}|"* ]]; then
key_files+=("$candidate")
seen_paths="${seen_paths}|${candidate}|"
fi
done
fi
# Also check well-known signing key names
for candidate in \ for candidate in \
"${SSH_DIR}/id_ed25519_sk_signing" "${SSH_DIR}/id_ed25519_sk_signing.pub" \
"${SSH_DIR}/id_ecdsa_sk_signing" "${SSH_DIR}/id_ecdsa_sk_signing.pub" \
"${SSH_DIR}/id_ed25519_signing" "${SSH_DIR}/id_ed25519_signing.pub" \
"${SSH_DIR}/id_ed25519_sk" "${SSH_DIR}/id_ed25519_sk.pub" \ "${SSH_DIR}/id_ed25519_sk" "${SSH_DIR}/id_ed25519_sk.pub" \
"${SSH_DIR}/id_ecdsa_sk" "${SSH_DIR}/id_ecdsa_sk.pub" \ "${SSH_DIR}/id_ecdsa_sk" "${SSH_DIR}/id_ecdsa_sk.pub" \
"${SSH_DIR}/id_ed25519" "${SSH_DIR}/id_ed25519.pub"; do "${SSH_DIR}/id_ed25519" "${SSH_DIR}/id_ed25519.pub"; do
[ -f "$candidate" ] && key_files+=("$candidate") if [[ -f "$candidate" ]] && [[ "$seen_paths" != *"|${candidate}|"* ]]; then
key_files+=("$candidate")
seen_paths="${seen_paths}|${candidate}|"
fi
done done
if (( ${#key_files[@]} > 0 )); then if (( ${#key_files[@]} > 0 )); then
@@ -1231,12 +1377,11 @@ enable_signing() {
} }
generate_ssh_key() { generate_ssh_key() {
local key_path="${SSH_DIR}/id_ed25519" local key_path="${SSH_DIR}/id_ed25519_signing"
if [ -f "$key_path" ]; then if [ -f "$key_path" ]; then
print_warn "$key_path already exists. Not overwriting." print_info "$key_path already exists — using existing key"
SIGNING_KEY_FOUND=true SIGNING_KEY_FOUND=true
SIGNING_PUB_PATH="${key_path}.pub" SIGNING_PUB_PATH="${key_path}.pub"
return return
fi fi
@@ -1302,18 +1447,18 @@ detect_fido2_sk_type() {
} }
generate_fido2_key() { generate_fido2_key() {
# Check for existing hardware-backed keys (both types) # Check for existing hardware-backed signing keys (both types)
local key_path_ed="${SSH_DIR}/id_ed25519_sk" local key_path_ed="${SSH_DIR}/id_ed25519_sk_signing"
local key_path_ec="${SSH_DIR}/id_ecdsa_sk" local key_path_ec="${SSH_DIR}/id_ecdsa_sk_signing"
if [ -f "$key_path_ed" ]; then if [ -f "$key_path_ed" ]; then
print_warn "$key_path_ed already exists. Not overwriting." print_info "$key_path_ed already exists — using existing key"
SIGNING_KEY_FOUND=true SIGNING_KEY_FOUND=true
SIGNING_PUB_PATH="${key_path_ed}.pub" SIGNING_PUB_PATH="${key_path_ed}.pub"
return return
fi fi
if [ -f "$key_path_ec" ]; then if [ -f "$key_path_ec" ]; then
print_warn "$key_path_ec already exists. Not overwriting." print_info "$key_path_ec already exists — using existing key"
SIGNING_KEY_FOUND=true SIGNING_KEY_FOUND=true
SIGNING_PUB_PATH="${key_path_ec}.pub" SIGNING_PUB_PATH="${key_path_ec}.pub"
return return
@@ -1362,32 +1507,6 @@ generate_fido2_key() {
fi fi
fi fi
# Qubes OS: USB passthrough (vhci_hcd) corrupts CTAP2 protocol messages.
# Keys with a FIDO2 PIN require CTAP2, which fails over vhci_hcd.
# Warn the user — PIN-protected keys won't work without a functioning
# qubes-ctap proxy.
if [ "$PLATFORM" = "linux" ] && [ -d /sys/bus/hid/devices ]; then
local has_vhci=false
local dev_dir
for dev_dir in /sys/class/hidraw/hidraw*; do
[ -d "$dev_dir" ] || continue
if grep -q 'vhci_hcd' "${dev_dir}/device/uevent" 2>/dev/null; then
has_vhci=true
break
fi
done
if [ "$has_vhci" = true ]; then
print_warn "Qubes OS detected — FIDO2 key is attached via USB passthrough (vhci_hcd)."
printf ' CTAP2 (PIN-protected keys) may fail over USB passthrough.\n' >&2
printf ' Keys without a PIN (U2F-only) should work.\n' >&2
printf ' For PIN-protected keys, generate on the host and copy the key pair,\n' >&2
printf ' or ensure qubes-ctap-proxy is fully configured.\n' >&2
if ! prompt_yn "Continue anyway?"; then
return
fi
fi
fi
# On macOS, the system ssh-keygen lacks FIDO2 support. Homebrew's openssh # On macOS, the system ssh-keygen lacks FIDO2 support. Homebrew's openssh
# bundles ssh-sk-helper and builds FIDO2 into its own ssh-keygen binary. # bundles ssh-sk-helper and builds FIDO2 into its own ssh-keygen binary.
# Detect by checking for ssh-sk-helper (NOT by running ssh-keygen, which # Detect by checking for ssh-sk-helper (NOT by running ssh-keygen, which
@@ -1492,6 +1611,22 @@ generate_fido2_key() {
break break
fi fi
# Device not found — offer to plug in and retry the same attempt
if printf '%s' "$keygen_stderr" | grep -qi 'device not found\|no device'; then
rm -f "$key_path" "${key_path}.pub"
printf '\n Security key not detected.\n' >&2
printf ' Please insert your security key and press Enter to retry (or q to skip): ' >&2
local retry_reply
read -r retry_reply </dev/tty || retry_reply="q"
if [[ "$retry_reply" = "q" ]]; then
return
fi
# Retry the same attempt (back up the index)
i=$((i - 1))
attempt_num=$((attempt_num - 1))
continue
fi
# Check for recoverable errors worth retrying with next attempt # Check for recoverable errors worth retrying with next attempt
if printf '%s' "$keygen_stderr" | grep -qi 'feature not supported\|unknown key type\|not supported\|invalid format'; then if printf '%s' "$keygen_stderr" | grep -qi 'feature not supported\|unknown key type\|not supported\|invalid format'; then
# Clean up any partial files before next attempt # Clean up any partial files before next attempt
@@ -1526,10 +1661,18 @@ setup_allowed_signers() {
local email local email
email="$(git config --global --get user.email 2>/dev/null || true)" email="$(git config --global --get user.email 2>/dev/null || true)"
if [ -z "$email" ]; then if [[ -z "$email" ]]; then
print_warn "user.email not set — cannot create allowed_signers entry" printf ' %ballowed_signers requires an email to match signatures.%b\n' "$YELLOW" "$RESET" >&2
printf ' Enter your email (or press Enter to skip): ' >&2
local input_email
read -r input_email </dev/tty || input_email=""
if [[ -n "$input_email" ]]; then
email="$input_email"
else
print_warn "No email provided — skipping allowed_signers (signature verification will show 'No principal matched')"
return return
fi fi
fi
mkdir -p "$(dirname "$ALLOWED_SIGNERS_FILE")" mkdir -p "$(dirname "$ALLOWED_SIGNERS_FILE")"
@@ -1552,27 +1695,31 @@ setup_allowed_signers() {
# SSH config hardening # SSH config hardening
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
apply_ssh_directive() { # 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"
[ "$(get_ssh_directive_value "$directive")" != "$value" ]
}
apply_single_ssh_directive() {
local directive="$1" local directive="$1"
local value="$2" local value="$2"
# Check if directive already exists with correct value (case-insensitive directive match)
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 [ "$current" = "$value" ]; then
return
fi
if [ -n "$current" ]; then if [ -n "$current" ]; then
# Directive exists but with wrong value # Replace existing directive
if prompt_yn "Update SSH directive: $directive $current -> $value?"; then
# Use temp file to avoid sed -i portability issues
local tmpfile local tmpfile
tmpfile="$(mktemp "${SSH_CONFIG}.XXXXXX")" tmpfile="$(mktemp "${SSH_CONFIG}.XXXXXX")"
trap 'rm -f "$tmpfile"' EXIT
# Replace first occurrence of the directive (case-insensitive)
local replaced=false local replaced=false
while IFS= read -r line || [ -n "$line" ]; do while IFS= read -r line || [ -n "$line" ]; do
if [ "$replaced" = false ] && printf '%s' "$line" | grep -qi "^[[:space:]]*${directive}[[:space:]=]"; then if [ "$replaced" = false ] && printf '%s' "$line" | grep -qi "^[[:space:]]*${directive}[[:space:]=]"; then
@@ -1584,14 +1731,83 @@ apply_ssh_directive() {
done < "$SSH_CONFIG" > "$tmpfile" done < "$SSH_CONFIG" > "$tmpfile"
mv "$tmpfile" "$SSH_CONFIG" mv "$tmpfile" "$SSH_CONFIG"
chmod 600 "$SSH_CONFIG" chmod 600 "$SSH_CONFIG"
print_info "Updated $directive = $value in $SSH_CONFIG"
fi
else else
# Directive missing entirely # Append inside a Host * block so it applies globally.
if prompt_yn "Add SSH directive: $directive $value?"; then # If no Host * block exists, prepend one before the first Host/Match block
printf '%s %s\n' "$directive" "$value" >> "$SSH_CONFIG" # (or append to EOF if the file has no blocks at all).
print_info "Added $directive $value to $SSH_CONFIG" if grep -qE '^[[:space:]]*Host[[:space:]]+\*[[:space:]]*$' "$SSH_CONFIG" 2>/dev/null; then
# Insert after the "Host *" line
local tmpfile
tmpfile="$(mktemp "${SSH_CONFIG}.XXXXXX")"
local inserted=false
while IFS= read -r line || [[ -n "$line" ]]; do
printf '%s\n' "$line"
if [[ "$inserted" = false ]] && printf '%s' "$line" | grep -qE '^[[:space:]]*Host[[:space:]]+\*[[:space:]]*$'; then
printf ' %s %s\n' "$directive" "$value"
inserted=true
fi fi
done < "$SSH_CONFIG" > "$tmpfile"
mv "$tmpfile" "$SSH_CONFIG"
chmod 600 "$SSH_CONFIG"
elif grep -qEi '^[[:space:]]*(Host|Match)[[:space:]]' "$SSH_CONFIG" 2>/dev/null; then
# File has Host/Match blocks but no Host *. Prepend a Host * section.
local tmpfile
tmpfile="$(mktemp "${SSH_CONFIG}.XXXXXX")"
{
printf 'Host *\n'
printf ' %s %s\n' "$directive" "$value"
printf '\n'
cat "$SSH_CONFIG"
} > "$tmpfile"
mv "$tmpfile" "$SSH_CONFIG"
chmod 600 "$SSH_CONFIG"
else
# No blocks at all — safe to append bare
printf '%s %s\n' "$directive" "$value" >> "$SSH_CONFIG"
fi
fi
}
apply_ssh_directive_group() {
local group_name="$1"
local description="$2"
shift 2
# Collect pending changes (directives that need updating)
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+=("$key")
pending_vals+=("$value")
pending_explanations+=("$explanation")
fi
done
local count="${#pending_keys[@]}"
if [ "$count" -eq 0 ]; then
return 0
fi
printf '\n %b%s%b\n' "$BOLD" "$group_name" "$RESET" >&2
printf ' %s\n\n' "$description" >&2
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
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 fi
} }
@@ -1610,13 +1826,37 @@ apply_ssh_config() {
touch "$SSH_CONFIG" touch "$SSH_CONFIG"
chmod 600 "$SSH_CONFIG" chmod 600 "$SSH_CONFIG"
print_info "Created $SSH_CONFIG with mode 600" print_info "Created $SSH_CONFIG with mode 600"
else
# Back up existing SSH config before modifying
local timestamp
timestamp="$(date +%Y%m%d-%H%M%S)"
local ssh_backup="${SSH_CONFIG}.pre-harden-${timestamp}"
cp -p "$SSH_CONFIG" "$ssh_backup"
print_info "SSH config backed up to $ssh_backup"
fi fi
apply_ssh_directive "StrictHostKeyChecking" "accept-new" apply_ssh_directive_group "Host Verification" \
apply_ssh_directive "HashKnownHosts" "yes" "Trust-on-first-use (TOFU): accept new host keys automatically, but reject
apply_ssh_directive "IdentitiesOnly" "yes" changed keys (the actual MITM scenario). The default 'ask' just trains users
apply_ssh_directive "AddKeysToAgent" "yes" to blindly type 'yes'. Hashing known_hosts prevents hostname enumeration if
apply_ssh_directive "PubkeyAcceptedAlgorithms" "ssh-ed25519,sk-ssh-ed25519@openssh.com,ecdsa-sha2-nistp256,sk-ecdsa-sha2-nistp256@openssh.com" the file is exfiltrated." \
"StrictHostKeyChecking" "accept-new" "Auto-accept new hosts, reject changed keys" \
"HashKnownHosts" "yes" "Hash hostnames in known_hosts (privacy)"
apply_ssh_directive_group "Key & Agent Management" \
"Without IdentitiesOnly, ssh-agent offers ALL loaded keys to every server —
a malicious server can enumerate which services you have access to.
AddKeysToAgent reduces passphrase fatigue so developers actually use them." \
"IdentitiesOnly" "yes" "Only offer keys explicitly configured (prevents key leakage)" \
"AddKeysToAgent" "yes" "Auto-add keys to ssh-agent after first use"
apply_ssh_directive_group "Algorithm Restrictions" \
"Disables RSA and DSA negotiation entirely. This prevents downgrade attacks
to weaker algorithms. May break connections to legacy servers that only
support RSA — those servers should be upgraded (RSA-SHA1 deprecated since
OpenSSH 8.7)." \
"PubkeyAcceptedAlgorithms" "ssh-ed25519,sk-ssh-ed25519@openssh.com,ecdsa-sha2-nistp256,sk-ecdsa-sha2-nistp256@openssh.com" \
"Ed25519 + ECDSA (software and hardware-backed)"
} }
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
@@ -1728,7 +1968,10 @@ main() {
apply_global_gitignore apply_global_gitignore
apply_signing_config apply_signing_config
apply_ssh_config apply_ssh_config
if [ "$MISSING_DEPENDENCY" = false ]; then
# Only show admin recommendations if everything completed without
# missing dependencies or incomplete signing setup
if [ "$MISSING_DEPENDENCY" = false ] && [ "$SIGNING_KEY_FOUND" = true ]; then
print_admin_recommendations print_admin_recommendations
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
@@ -570,18 +570,29 @@ SSHEOF
[ "$SIGNING_PUB_PATH" = "${TEST_HOME}/.ssh/id_ed25519.pub" ] [ "$SIGNING_PUB_PATH" = "${TEST_HOME}/.ssh/id_ed25519.pub" ]
} }
@test "detect_existing_keys prefers sk key over software key" { @test "detect_existing_keys prefers dedicated signing key over general key" {
ssh-keygen -t ed25519 -f "${TEST_HOME}/.ssh/id_ed25519" -N "" -q ssh-keygen -t ed25519 -f "${TEST_HOME}/.ssh/id_ed25519" -N "" -q
# Fake an sk key (can't generate real one without hardware) ssh-keygen -t ed25519 -f "${TEST_HOME}/.ssh/id_ed25519_signing" -N "" -q
cp "${TEST_HOME}/.ssh/id_ed25519" "${TEST_HOME}/.ssh/id_ed25519_sk"
# Write a fake pub key with sk type prefix
printf 'sk-ssh-ed25519@openssh.com AAAAFakeKey test\n' > "${TEST_HOME}/.ssh/id_ed25519_sk.pub"
source_functions source_functions
detect_existing_keys detect_existing_keys
[ "$SIGNING_KEY_FOUND" = true ] [ "$SIGNING_KEY_FOUND" = true ]
[ "$SIGNING_PUB_PATH" = "${TEST_HOME}/.ssh/id_ed25519_sk.pub" ] [ "$SIGNING_PUB_PATH" = "${TEST_HOME}/.ssh/id_ed25519_signing.pub" ]
}
@test "detect_existing_keys prefers sk signing key over software key" {
ssh-keygen -t ed25519 -f "${TEST_HOME}/.ssh/id_ed25519" -N "" -q
# Fake an sk signing key (can't generate real one without hardware)
cp "${TEST_HOME}/.ssh/id_ed25519" "${TEST_HOME}/.ssh/id_ed25519_sk_signing"
# Write a fake pub key with sk type prefix
printf 'sk-ssh-ed25519@openssh.com AAAAFakeKey test\n' > "${TEST_HOME}/.ssh/id_ed25519_sk_signing.pub"
source_functions
detect_existing_keys
[ "$SIGNING_KEY_FOUND" = true ]
[ "$SIGNING_PUB_PATH" = "${TEST_HOME}/.ssh/id_ed25519_sk_signing.pub" ]
} }
@test "detect_existing_keys finds key from IdentityFile directive" { @test "detect_existing_keys finds key from IdentityFile directive" {
@@ -691,15 +702,16 @@ SSHEOF
[ "$count" -eq 1 ] [ "$count" -eq 1 ]
} }
@test "setup_allowed_signers skips when no email set" { @test "setup_allowed_signers skips when no email provided" {
ssh-keygen -t ed25519 -f "${TEST_HOME}/.ssh/id_ed25519" -N "" -q ssh-keygen -t ed25519 -f "${TEST_HOME}/.ssh/id_ed25519" -N "" -q
git config --global --unset user.email git config --global --unset user.email
source_functions source_functions
SIGNING_PUB_PATH="${TEST_HOME}/.ssh/id_ed25519.pub" SIGNING_PUB_PATH="${TEST_HOME}/.ssh/id_ed25519.pub"
# In non-interactive context, read from /dev/tty fails — empty email
run setup_allowed_signers run setup_allowed_signers
assert_output --partial "user.email not set" assert_output --partial "No email provided"
} }
# =========================================================================== # ===========================================================================
@@ -1153,10 +1165,297 @@ EOF
} }
# =========================================================================== # ===========================================================================
# v0.2.0: Version bump # v0.5.0: Identity guard (useConfigOnly)
# =========================================================================== # ===========================================================================
@test "--version reports 0.2.3" { @test "audit warns when useConfigOnly=true but identity missing" {
run bash "$SCRIPT" --version git config --global --unset user.name
assert_output --partial "0.2.3" git config --global --unset user.email
source_functions
run audit_git_config
assert_output --partial "user.name/user.email not set"
}
@test "audit does not warn about identity when name and email set" {
source_functions
run audit_git_config
refute_output --partial "user.name/user.email not set"
}
@test "-y mode applies useConfigOnly when identity exists" {
source_functions
AUTO_YES=true
PLATFORM="macos"
DETECTED_CRED_HELPER="osxkeychain"
run apply_git_config
assert_success
[ "$(git config --global user.useConfigOnly)" = "true" ]
}
@test "-y mode skips useConfigOnly when user.name missing" {
git config --global --unset user.name
source_functions
AUTO_YES=true
PLATFORM="macos"
DETECTED_CRED_HELPER="osxkeychain"
run apply_git_config
assert_success
assert_output --partial "Skipping user.useConfigOnly"
local result
result="$(git config --global --get user.useConfigOnly 2>/dev/null || true)"
[ -z "$result" ]
}
@test "-y mode skips useConfigOnly when user.email missing" {
git config --global --unset user.email
source_functions
AUTO_YES=true
PLATFORM="macos"
DETECTED_CRED_HELPER="osxkeychain"
run apply_git_config
assert_success
assert_output --partial "Skipping user.useConfigOnly"
local result
result="$(git config --global --get user.useConfigOnly 2>/dev/null || true)"
[ -z "$result" ]
}
# ===========================================================================
# v0.5.0: pull.rebase unset during apply
# ===========================================================================
@test "-y mode unsets pull.rebase when set" {
git config --global pull.rebase true
source_functions
AUTO_YES=true
PLATFORM="macos"
DETECTED_CRED_HELPER="osxkeychain"
run apply_git_config
assert_success
assert_output --partial "Unset pull.rebase"
local result
result="$(git config --global --get pull.rebase 2>/dev/null || true)"
[ -z "$result" ]
}
@test "-y mode does not unset pull.rebase when not set" {
source_functions
AUTO_YES=true
PLATFORM="macos"
DETECTED_CRED_HELPER="osxkeychain"
run apply_git_config
assert_success
refute_output --partial "Unset pull.rebase"
}
# ===========================================================================
# v0.5.0: SSH directives in Host * block
# ===========================================================================
@test "apply places new SSH directive in Host * block when blocks exist" {
cat > "${TEST_HOME}/.ssh/config" <<'SSHEOF'
Host github.com
IdentityFile ~/.ssh/github_key
SSHEOF
source_functions
apply_single_ssh_directive "StrictHostKeyChecking" "accept-new"
# Should have created a Host * block
grep -q "^Host \*$" "${TEST_HOME}/.ssh/config"
grep -q "StrictHostKeyChecking accept-new" "${TEST_HOME}/.ssh/config"
}
@test "apply inserts into existing Host * block" {
cat > "${TEST_HOME}/.ssh/config" <<'SSHEOF'
Host *
HashKnownHosts yes
Host github.com
IdentityFile ~/.ssh/github_key
SSHEOF
source_functions
apply_single_ssh_directive "IdentitiesOnly" "yes"
# Should be inside Host * block (indented), not appended bare
grep -q "IdentitiesOnly yes" "${TEST_HOME}/.ssh/config"
# Only one Host * line
local count
count="$(grep -c '^Host \*$' "${TEST_HOME}/.ssh/config")"
[ "$count" -eq 1 ]
}
@test "apply appends bare when no Host/Match blocks exist" {
: > "${TEST_HOME}/.ssh/config"
source_functions
apply_single_ssh_directive "HashKnownHosts" "yes"
grep -q "HashKnownHosts yes" "${TEST_HOME}/.ssh/config"
# No Host * block should be created for a simple file
! grep -q "^Host" "${TEST_HOME}/.ssh/config"
}
# ===========================================================================
# v0.5.0: SSH config backup
# ===========================================================================
@test "apply_ssh_config creates backup of existing SSH config" {
printf 'StrictHostKeyChecking ask\n' > "${TEST_HOME}/.ssh/config"
source_functions
AUTO_YES=true
run apply_ssh_config
assert_success
assert_output --partial "SSH config backed up"
# Verify backup file exists
local backup_count
backup_count="$(find "${TEST_HOME}/.ssh" -name 'config.pre-harden-*' | wc -l | tr -d ' ')"
[ "$backup_count" -eq 1 ]
# Verify backup contains original content
local backup_file
backup_file="$(find "${TEST_HOME}/.ssh" -name 'config.pre-harden-*' -print -quit)"
grep -q "StrictHostKeyChecking ask" "$backup_file"
}
@test "apply_ssh_config does not create backup for new SSH config" {
rm -f "${TEST_HOME}/.ssh/config"
source_functions
AUTO_YES=true
run apply_ssh_config
assert_success
refute_output --partial "SSH config backed up"
}
# ===========================================================================
# v0.5.0: Dedicated signing key names
# ===========================================================================
@test "detect_existing_keys finds dedicated signing key" {
ssh-keygen -t ed25519 -f "${TEST_HOME}/.ssh/id_ed25519_signing" -N "" -q
source_functions
detect_existing_keys
[ "$SIGNING_KEY_FOUND" = true ]
[ "$SIGNING_PUB_PATH" = "${TEST_HOME}/.ssh/id_ed25519_signing.pub" ]
}
@test "detect_existing_keys falls back to general key when no signing key" {
ssh-keygen -t ed25519 -f "${TEST_HOME}/.ssh/id_ed25519" -N "" -q
source_functions
detect_existing_keys
[ "$SIGNING_KEY_FOUND" = true ]
[ "$SIGNING_PUB_PATH" = "${TEST_HOME}/.ssh/id_ed25519.pub" ]
}
@test "-y mode enables signing with dedicated signing key" {
ssh-keygen -t ed25519 -f "${TEST_HOME}/.ssh/id_ed25519_signing" -N "" -q
source_functions
AUTO_YES=true
run apply_signing_config
assert_success
[ "$(git config --global commit.gpgsign)" = "true" ]
local sigkey
sigkey="$(git config --global user.signingkey)"
[[ "$sigkey" = *"id_ed25519_signing.pub"* ]]
}
# ===========================================================================
# v0.5.0: core.hooksPath separate prompt
# ===========================================================================
@test "-y mode applies core.hooksPath separately from filesystem group" {
source_functions
AUTO_YES=true
PLATFORM="macos"
DETECTED_CRED_HELPER="osxkeychain"
run apply_git_config
assert_success
[ "$(git config --global core.hooksPath)" = "~/.config/git/hooks" ]
}
@test "-y mode skips core.hooksPath when already set" {
git config --global core.hooksPath "~/.config/git/hooks"
source_functions
AUTO_YES=true
PLATFORM="macos"
DETECTED_CRED_HELPER="osxkeychain"
run apply_git_config
assert_success
refute_output --partial "Global Hooks Path"
}
# ===========================================================================
# v0.5.0: reset-signing cleans configured key path
# ===========================================================================
@test "reset-signing cleans actual configured key path" {
# Create a custom-named key
ssh-keygen -t ed25519 -f "${TEST_HOME}/.ssh/my_org_key" -N "" -q
git config --global user.signingkey "${TEST_HOME}/.ssh/my_org_key.pub"
git config --global commit.gpgsign true
source_functions
AUTO_YES=true
run reset_signing
assert_success
# git config entries should be removed
local sigkey
sigkey="$(git config --global --get user.signingkey 2>/dev/null || true)"
[ -z "$sigkey" ]
# Key files should be listed for cleanup
assert_output --partial "my_org_key"
}
@test "reset-signing includes dedicated signing key names" {
# Create dedicated signing keys
ssh-keygen -t ed25519 -f "${TEST_HOME}/.ssh/id_ed25519_signing" -N "" -q
source_functions
AUTO_YES=true
run reset_signing
assert_success
assert_output --partial "id_ed25519_signing"
}
# ===========================================================================
# v0.5.0: Version bump
# ===========================================================================
@test "--version reports 0.5.0" {
run bash "$SCRIPT" --version
assert_output --partial "0.5.0"
} }

View File

@@ -0,0 +1,106 @@
#!/usr/bin/env bash
# Interactive test: identity guard prevents useConfigOnly lockout
# Verifies: when user.name/email are missing, the script prompts for them
# before enabling useConfigOnly; after providing both, useConfigOnly is set.
set -o errexit
set -o nounset
set -o pipefail
IFS=$'\n\t'
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=helpers.sh
source "${SCRIPT_DIR}/helpers.sh"
main() {
trap cleanup EXIT
printf 'Test: Identity guard — missing name/email\n' >&2
# Remove identity AND useConfigOnly so the guard triggers
git config --global --unset user.name 2>/dev/null || true
git config --global --unset user.email 2>/dev/null || true
git config --global --unset user.useConfigOnly 2>/dev/null || true
# Remove signing keys so wizard shows options (not existing key prompt)
rm -f "${HOME}/.ssh/id_ed25519_signing" "${HOME}/.ssh/id_ed25519_signing.pub"
rm -f "${HOME}/.ssh/id_ed25519" "${HOME}/.ssh/id_ed25519.pub"
start_session
# Safety review gate
wait_for "reviewed this script"
send "y" Enter
# Proceed with hardening
wait_for "Proceed with hardening"
send "y" Enter
# Accept settings until identity guard prompt appears
local pane_content
for _ in $(seq 1 50); do
sleep 0.3
pane_content="$(tmux capture-pane -t "$TMUX_SESSION" -p 2>/dev/null || true)"
if printf '%s' "$pane_content" | grep -qF "Enter your name"; then
break
fi
if printf '%s' "$pane_content" | grep -qF "Hardening complete"; then
fail "Identity guard did not trigger — reached completion"
exit 1
fi
send "y" Enter
done
# Identity guard: enter name
wait_for "Enter your name" 15
send "Test User" Enter
# Identity guard: enter email
wait_for "Enter your email" 10
send "test@example.com" Enter
# Continue accepting remaining prompts
for _ in $(seq 1 50); do
sleep 0.3
pane_content="$(tmux capture-pane -t "$TMUX_SESSION" -p 2>/dev/null || true)"
if printf '%s' "$pane_content" | grep -qF "Signing key options"; then
break
fi
if printf '%s' "$pane_content" | grep -qF "Hardening complete"; then
break
fi
send "y" Enter
done
# Skip signing
if tmux capture-pane -t "$TMUX_SESSION" -p | grep -qF "Signing key options"; then
send "s" Enter
fi
# Wait for completion
sleep 2
capture_output >/dev/null 2>&1 || true
# Verify: useConfigOnly was set
local use_config_only
use_config_only="$(git config --global --get user.useConfigOnly 2>/dev/null || true)"
if [ "$use_config_only" = "true" ]; then
pass "Identity guard: useConfigOnly=true set after providing name/email"
else
fail "Identity guard: useConfigOnly not set (expected true, got '${use_config_only}')"
exit 1
fi
# Verify: name and email were set
local name email
name="$(git config --global --get user.name 2>/dev/null || true)"
email="$(git config --global --get user.email 2>/dev/null || true)"
if [ "$name" = "Test User" ] && [ "$email" = "test@example.com" ]; then
pass "Identity guard: user.name and user.email configured"
else
fail "Identity guard: identity not configured (name='${name}', email='${email}')"
exit 1
fi
}
main

View File

@@ -16,7 +16,12 @@ main() {
printf 'Test: Signing wizard - generate ed25519 key\n' >&2 printf 'Test: Signing wizard - generate ed25519 key\n' >&2
# Ensure no existing keys # Ensure identity is set (prior tests may have cleared it)
git config --global user.name "Test User" 2>/dev/null || true
git config --global user.email "test@example.com" 2>/dev/null || true
# Ensure no existing signing keys (new dedicated names + legacy)
rm -f "${HOME}/.ssh/id_ed25519_signing" "${HOME}/.ssh/id_ed25519_signing.pub"
rm -f "${HOME}/.ssh/id_ed25519" "${HOME}/.ssh/id_ed25519.pub" rm -f "${HOME}/.ssh/id_ed25519" "${HOME}/.ssh/id_ed25519.pub"
start_session start_session
@@ -61,9 +66,9 @@ main() {
sleep 3 sleep 3
capture_output >/dev/null 2>&1 || true capture_output >/dev/null 2>&1 || true
# Verify key exists # Verify key exists (new dedicated signing key name)
if [ -f "${HOME}/.ssh/id_ed25519.pub" ]; then if [ -f "${HOME}/.ssh/id_ed25519_signing.pub" ]; then
pass "Key generated: ~/.ssh/id_ed25519.pub exists" pass "Key generated: ~/.ssh/id_ed25519_signing.pub exists"
else else
fail "Key not generated" fail "Key not generated"
exit 1 exit 1

View File

@@ -17,6 +17,9 @@ main() {
printf 'Test: Signing wizard - skip\n' >&2 printf 'Test: Signing wizard - skip\n' >&2
# Remove any keys from prior tests so wizard shows key generation options # Remove any keys from prior tests so wizard shows key generation options
rm -f "${HOME}/.ssh/id_ed25519_signing" "${HOME}/.ssh/id_ed25519_signing.pub"
rm -f "${HOME}/.ssh/id_ed25519_sk_signing" "${HOME}/.ssh/id_ed25519_sk_signing.pub"
rm -f "${HOME}/.ssh/id_ecdsa_sk_signing" "${HOME}/.ssh/id_ecdsa_sk_signing.pub"
rm -f "${HOME}/.ssh/id_ed25519" "${HOME}/.ssh/id_ed25519.pub" rm -f "${HOME}/.ssh/id_ed25519" "${HOME}/.ssh/id_ed25519.pub"
rm -f "${HOME}/.ssh/id_ed25519_sk" "${HOME}/.ssh/id_ed25519_sk.pub" rm -f "${HOME}/.ssh/id_ed25519_sk" "${HOME}/.ssh/id_ed25519_sk.pub"
git config --global --unset user.signingkey 2>/dev/null || true git config --global --unset user.signingkey 2>/dev/null || true