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>
This commit is contained in:
Flo
2026-04-05 03:25:48 -07:00
parent 69707b4475
commit c5bbe5b44a
2 changed files with 186 additions and 24 deletions

View File

@@ -4,6 +4,24 @@ 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
### Fixed
- `readonly VERSION` variable conflict when sourcing `/etc/os-release` (replaced `.` with `sed` parse)
## [0.4.0] - 2026-04-04 ## [0.4.0] - 2026-04-04
### Added ### Added

View File

@@ -10,7 +10,7 @@ IFS=$'\n\t'
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Constants # Constants
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
readonly VERSION="0.4.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"
@@ -318,9 +318,8 @@ detect_credential_helper() {
# Print distro-specific install hints for keychain credential storage. # Print distro-specific install hints for keychain credential storage.
credential_install_hint() { credential_install_hint() {
local distro_id="" local distro_id=""
if [ -f /etc/os-release ]; then if [[ -f /etc/os-release ]]; then
# shellcheck disable=SC1091 # os-release is a system file, not part of this project distro_id="$(sed -n 's/^ID=//p' /etc/os-release | tr -d '"')"
distro_id="$(. /etc/os-release && printf '%s' "${ID:-}")"
fi fi
printf ' %bTo store credentials in the OS keychain, install one of:%b\n' "$YELLOW" "$RESET" >&2 printf ' %bTo store credentials in the OS keychain, install one of:%b\n' "$YELLOW" "$RESET" >&2
@@ -378,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"
@@ -831,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
@@ -879,16 +900,72 @@ 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"
# user.useConfigOnly needs a guard — it locks out commits without identity
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 # Credential helper needs special logic — accept any keychain-backed helper
if is_keychain_credential_helper "$cred_current" 2>/dev/null; then if is_keychain_credential_helper "$cred_current" 2>/dev/null; then
: # Already using a keychain-backed helper — leave it alone : # Already using a keychain-backed helper — leave it alone
@@ -1075,9 +1152,9 @@ detect_existing_keys() {
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
@@ -1230,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
@@ -1279,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
@@ -1350,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
@@ -1574,10 +1671,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")"
@@ -1637,8 +1742,40 @@ apply_single_ssh_directive() {
mv "$tmpfile" "$SSH_CONFIG" mv "$tmpfile" "$SSH_CONFIG"
chmod 600 "$SSH_CONFIG" chmod 600 "$SSH_CONFIG"
else else
# Append inside a Host * block so it applies globally.
# If no Host * block exists, prepend one before the first Host/Match block
# (or append to EOF if the file has no blocks at all).
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
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" printf '%s %s\n' "$directive" "$value" >> "$SSH_CONFIG"
fi fi
fi
} }
apply_ssh_directive_group() { apply_ssh_directive_group() {
@@ -1699,6 +1836,13 @@ 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_group "Host Verification" \ apply_ssh_directive_group "Host Verification" \