1 Commits

Author SHA1 Message Date
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

View File

@@ -10,7 +10,7 @@ IFS=$'\n\t'
# ------------------------------------------------------------------------------
# Constants
# ------------------------------------------------------------------------------
readonly VERSION="0.3.0"
readonly VERSION="0.3.1"
readonly BACKUP_DIR="${HOME}/.config/git"
readonly HOOKS_DIR="${HOME}/.config/git/hooks"
readonly ALLOWED_SIGNERS_FILE="${HOME}/.config/git/allowed_signers"
@@ -1552,27 +1552,29 @@ setup_allowed_signers() {
# SSH config hardening
# ------------------------------------------------------------------------------
apply_ssh_directive() {
ssh_directive_needs_change() {
local directive="$1"
local value="$2"
# Check if directive already exists with correct value (case-insensitive directive match)
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")"
if [ "$current" = "$value" ]; then
return
fi
[ "$current" != "$value" ]
}
apply_single_ssh_directive() {
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")"
if [ -n "$current" ]; then
# Directive exists but with wrong value
if prompt_yn "Update SSH directive: $directive $current -> $value?"; then
# Use temp file to avoid sed -i portability issues
# Replace existing directive
local tmpfile
tmpfile="$(mktemp "${SSH_CONFIG}.XXXXXX")"
trap 'rm -f "$tmpfile"' EXIT
# Replace first occurrence of the directive (case-insensitive)
local replaced=false
while IFS= read -r line || [ -n "$line" ]; do
if [ "$replaced" = false ] && printf '%s' "$line" | grep -qi "^[[:space:]]*${directive}[[:space:]=]"; then
@@ -1584,14 +1586,61 @@ apply_ssh_directive() {
done < "$SSH_CONFIG" > "$tmpfile"
mv "$tmpfile" "$SSH_CONFIG"
chmod 600 "$SSH_CONFIG"
print_info "Updated $directive = $value in $SSH_CONFIG"
fi
else
# Directive missing entirely
if prompt_yn "Add SSH directive: $directive $value?"; then
printf '%s %s\n' "$directive" "$value" >> "$SSH_CONFIG"
print_info "Added $directive $value to $SSH_CONFIG"
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=""
local count=0
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))
fi
done
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=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))
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))
done
print_info "Applied ${count} SSH directives"
fi
}
@@ -1612,11 +1661,28 @@ apply_ssh_config() {
print_info "Created $SSH_CONFIG with mode 600"
fi
apply_ssh_directive "StrictHostKeyChecking" "accept-new"
apply_ssh_directive "HashKnownHosts" "yes"
apply_ssh_directive "IdentitiesOnly" "yes"
apply_ssh_directive "AddKeysToAgent" "yes"
apply_ssh_directive "PubkeyAcceptedAlgorithms" "ssh-ed25519,sk-ssh-ed25519@openssh.com,ecdsa-sha2-nistp256,sk-ecdsa-sha2-nistp256@openssh.com"
apply_ssh_directive_group "Host Verification" \
"Trust-on-first-use (TOFU): accept new host keys automatically, but reject
changed keys (the actual MITM scenario). The default 'ask' just trains users
to blindly type 'yes'. Hashing known_hosts prevents hostname enumeration if
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)"
}
# ------------------------------------------------------------------------------