2 Commits

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
7 changed files with 650 additions and 72 deletions

View File

@@ -4,6 +4,34 @@ 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.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

View File

@@ -165,11 +165,11 @@ These combinations of hardware and OS have been tested:
| [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 | 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| |
| [Yubico YubiKey 5 NFC](https://support.yubico.com/s/article/YubiKey-5-NFC) | 5.1.2 | 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 |
@@ -177,7 +177,7 @@ These combinations of hardware and OS have been tested:
| [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 | |
| [HYPERSECU HyperFIDO mini](https://033c2a7e-e1da-473d-a255-6132a1d3aa6e.filesusr.com/ugd/5aae8d_f4e8a196a99f45b1859e201a7cb40962.pdf) | | Fedora 42 | Yes |
## Running Tests

View File

@@ -10,7 +10,7 @@ IFS=$'\n\t'
# ------------------------------------------------------------------------------
# Constants
# ------------------------------------------------------------------------------
readonly VERSION="0.4.0"
readonly VERSION="0.5.0"
readonly BACKUP_DIR="${HOME}/.config/git"
readonly HOOKS_DIR="${HOME}/.config/git/hooks"
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.
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:-}")"
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
@@ -378,6 +377,14 @@ audit_git_config() {
print_header "Identity"
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"
audit_git_setting "transfer.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.protectHFS" "true" "Block HFS+ Unicode normalization attacks" \
"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" \
"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)
if [ "$AUTO_YES" = false ]; then
local current_symlinks
@@ -879,16 +900,72 @@ apply_git_config() {
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 ---
local cred_current
cred_current="$(git config --global --get credential.helper 2>/dev/null || true)"
apply_setting_group "Identity, Credentials & Defaults" \
"Prevent accidental identity, enforce secure credential storage." \
"user.useConfigOnly" "true" "Block commits without explicit user.name/email" \
apply_setting_group "Defaults & Visibility" \
"Sensible defaults for new repositories and log output." \
"init.defaultBranch" "main" "Default branch name for new repos" \
"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
if is_keychain_credential_helper "$cred_current" 2>/dev/null; then
: # Already using a keychain-backed helper — leave it alone
@@ -1075,9 +1152,9 @@ detect_existing_keys() {
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
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}"
pub_path="${priv_path}.pub"
if [ -f "$pub_path" ]; then
@@ -1230,14 +1307,35 @@ reset_signing() {
print_info "No signing key in git config"
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 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 \
"${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_ecdsa_sk" "${SSH_DIR}/id_ecdsa_sk.pub" \
"${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
if (( ${#key_files[@]} > 0 )); then
@@ -1279,12 +1377,11 @@ enable_signing() {
}
generate_ssh_key() {
local key_path="${SSH_DIR}/id_ed25519"
local key_path="${SSH_DIR}/id_ed25519_signing"
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_PUB_PATH="${key_path}.pub"
return
fi
@@ -1350,18 +1447,18 @@ detect_fido2_sk_type() {
}
generate_fido2_key() {
# Check for existing hardware-backed keys (both types)
local key_path_ed="${SSH_DIR}/id_ed25519_sk"
local key_path_ec="${SSH_DIR}/id_ecdsa_sk"
# Check for existing hardware-backed signing keys (both types)
local key_path_ed="${SSH_DIR}/id_ed25519_sk_signing"
local key_path_ec="${SSH_DIR}/id_ecdsa_sk_signing"
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_PUB_PATH="${key_path_ed}.pub"
return
fi
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_PUB_PATH="${key_path_ec}.pub"
return
@@ -1410,32 +1507,6 @@ generate_fido2_key() {
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
# 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
@@ -1540,6 +1611,22 @@ generate_fido2_key() {
break
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
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
@@ -1574,10 +1661,18 @@ setup_allowed_signers() {
local email
email="$(git config --global --get user.email 2>/dev/null || true)"
if [ -z "$email" ]; then
print_warn "user.email not set — cannot create allowed_signers entry"
if [[ -z "$email" ]]; then
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
fi
fi
mkdir -p "$(dirname "$ALLOWED_SIGNERS_FILE")"
@@ -1637,8 +1732,40 @@ apply_single_ssh_directive() {
mv "$tmpfile" "$SSH_CONFIG"
chmod 600 "$SSH_CONFIG"
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"
fi
fi
}
apply_ssh_directive_group() {
@@ -1699,6 +1826,13 @@ apply_ssh_config() {
touch "$SSH_CONFIG"
chmod 600 "$SSH_CONFIG"
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
apply_ssh_directive_group "Host Verification" \
@@ -1834,7 +1968,10 @@ main() {
apply_global_gitignore
apply_signing_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
fi

View File

@@ -570,18 +570,29 @@ SSHEOF
[ "$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
# Fake an sk key (can't generate real one without hardware)
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"
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_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" {
@@ -691,15 +702,16 @@ SSHEOF
[ "$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
git config --global --unset user.email
source_functions
SIGNING_PUB_PATH="${TEST_HOME}/.ssh/id_ed25519.pub"
# In non-interactive context, read from /dev/tty fails — empty email
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.4.0" {
run bash "$SCRIPT" --version
assert_output --partial "0.4.0"
@test "audit warns when useConfigOnly=true but identity missing" {
git config --global --unset user.name
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
# 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"
start_session
@@ -61,9 +66,9 @@ main() {
sleep 3
capture_output >/dev/null 2>&1 || true
# Verify key exists
if [ -f "${HOME}/.ssh/id_ed25519.pub" ]; then
pass "Key generated: ~/.ssh/id_ed25519.pub exists"
# Verify key exists (new dedicated signing key name)
if [ -f "${HOME}/.ssh/id_ed25519_signing.pub" ]; then
pass "Key generated: ~/.ssh/id_ed25519_signing.pub exists"
else
fail "Key not generated"
exit 1

View File

@@ -17,6 +17,9 @@ main() {
printf 'Test: Signing wizard - skip\n' >&2
# 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_sk" "${HOME}/.ssh/id_ed25519_sk.pub"
git config --global --unset user.signingkey 2>/dev/null || true