From fec16225e421f56ad2690e6e3c5871a699435ba7 Mon Sep 17 00:00:00 2001 From: David Tomaschik Date: Tue, 21 Apr 2026 14:39:32 -0700 Subject: [PATCH] Build update-authorized-keys --- bin/update-authorized-keys | 310 +++++++++++++++++++++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100755 bin/update-authorized-keys diff --git a/bin/update-authorized-keys b/bin/update-authorized-keys new file mode 100755 index 0000000..59a4d4e --- /dev/null +++ b/bin/update-authorized-keys @@ -0,0 +1,310 @@ +#!/usr/bin/env bash + +# update-authorized-keys - Manage ~/.ssh/authorized_keys from multiple sources +# +# BEHAVIOR: +# 1. Collects SSH public keys from one or more source directories (default: ~/.ssh/authorized_keys.d). +# 2. Skips empty files and files symlinked to /dev/null (masking). +# 3. Deterministically concatenates keys into a "managed block" wrapped in markers: +# # BEGIN UPDATE-AUTHORIZED-KEYS +# # END UPDATE-AUTHORIZED-KEYS +# 4. Deduplicates managed keys: if the same key (including options) is found in multiple files, +# it is included once with a comment listing all source filenames. +# 5. Preserves "manual" keys found in the target file outside the markers. +# 6. Removes manual keys that exactly match a managed key (options + key data). +# 7. Validates every proposed key individually using 'ssh-keygen -l -f'. +# 8. Optionally validates the whole file with 'authorized-keys-test' if available. +# 9. Displays a unified diff and prompts for confirmation before atomic replacement. +# 10. Supports a --dry-run mode and a --self-test mode for verifying logic. + +set -o nounset +set -o errexit +set -o pipefail + +CLEANUP_FILES=() +cleanup() { + rm -rf "${CLEANUP_FILES[@]}" +} +trap cleanup EXIT + +# Configuration +DEFAULT_DIR="${HOME}/.ssh/authorized_keys.d" +DEFAULT_TARGET="${HOME}/.ssh/authorized_keys" +BEGIN_MARKER="# BEGIN UPDATE-AUTHORIZED-KEYS" +END_MARKER="# END UPDATE-AUTHORIZED-KEYS" + +# State +SOURCE_DIRS=() +TARGET_FILE="${DEFAULT_TARGET}" +DRY_RUN=0 + +usage() { + cat < "${d1}/k1" + echo "${key1}" > "${d2}/k1_dup" + echo "${key2}" > "${d2}/k2" + echo "${long_opt} ${key1}" > "${d1}/k1_long" + echo "${long_opt} ${key2}" > "${d1}/k2_long" + ln -s /dev/null "${d1}/masked" + + cat < "${target}" +${key_man} +${key1} # This should be removed as it's now managed +EOF + + echo "Executing script in test mode..." + # Pipe "y" to handle the TTY check if we are not in a TTY during test + echo "y" | "$0" --dir "${d1}" --extra-dir "${d2}" --target "${target}" > /dev/null + + local content=$(cat "${target}") + + echo -n "Check markers... " + if [[ "${content}" == *"${BEGIN_MARKER}"* && "${content}" == *"${END_MARKER}"* ]]; then echo "OK"; else echo "FAIL"; exit 1; fi + + echo -n "Check managed deduplication... " + if grep -q "Source: k1, k1_dup" "${target}"; then echo "OK"; else echo "FAIL"; exit 1; fi + + echo -n "Check long option deduplication (should NOT deduplicate different keys)... " + if grep -q "k1_long" "${target}" && grep -q "k2_long" "${target}"; then echo "OK"; else echo "FAIL"; exit 1; fi + + echo -n "Check manual key preservation... " + if grep -q "manual" "${target}"; then echo "OK"; else echo "FAIL"; exit 1; fi + + echo -n "Check manual key filtering... " + local manual_count=$(grep -c "${key1}" "${target}") + # key1 appears twice in managed block (once plain, once with long opt) + # and it was in manual block. The manual one should be removed. + # So we expect 2 occurrences in the final file (both in managed block). + if [[ ${manual_count} -eq 2 ]]; then echo "OK"; else echo "FAIL (Found ${manual_count} occurrences, expected 2)"; exit 1; fi + + echo -n "Check masking... " + if ! grep -q "masked" "${target}"; then echo "OK"; else echo "FAIL"; exit 1; fi + + echo "Self-test passed successfully!" + exit 0 +} + +# Parse arguments +while [[ $# -gt 0 ]]; do + case "$1" in + --dir) + [[ -z "${2:-}" ]] && { echo "Error: --dir requires an argument" >&2; exit 1; } + SOURCE_DIRS+=("$2"); shift 2 ;; + --extra-dir) + [[ -z "${2:-}" ]] && { echo "Error: --extra-dir requires an argument" >&2; exit 1; } + SOURCE_DIRS+=("$2"); shift 2 ;; + --target) + [[ -z "${2:-}" ]] && { echo "Error: --target requires an argument" >&2; exit 1; } + TARGET_FILE="$2"; shift 2 ;; + --dry-run) DRY_RUN=1; shift ;; + --self-test) run_self_test ;; + --help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage; exit 1 ;; + esac +done + +if [[ ${#SOURCE_DIRS[@]} -eq 0 ]]; then + SOURCE_DIRS+=("${DEFAULT_DIR}") +fi + +mkdir -p "$(dirname "${TARGET_FILE}")" +TMP_FILE=$(mktemp) +CLEANUP_FILES+=("${TMP_FILE}") + +collect_keys() { + local dirs=("${@}") + for dir in "${dirs[@]}"; do + if [[ ! -d "${dir}" ]]; then continue; fi + # Use a glob to avoid parsing ls + for file in "${dir}"/*; do + [[ ! -e "${file}" ]] && continue + [[ ! -f "${file}" || ! -s "${file}" ]] && continue + if [[ -L "${file}" && "$(readlink "${file}")" == "/dev/null" ]]; then continue; fi + while read -r line; do + [[ -z "${line}" || "${line}" =~ ^[[:space:]]*# ]] && continue + # Use a specific delimiter that is unlikely to be in the key or filename + # If using tabs, ensure we only split on the first one in AWK + printf "%s\t%s\n" "$(basename "${file}")" "${line}" + done < "${file}" + done + done +} + +# Use a HEREDOC for the complex AWK script to avoid shell interpolation issues +MANAGED_BLOCK=$(collect_keys "${SOURCE_DIRS[@]}" | awk -F'\t' ' +{ + # Splitting on the first tab manually to be robust + tab_idx = index($0, "\t") + source = substr($0, 1, tab_idx - 1) + full_line = substr($0, tab_idx + 1) + + # Signature detection: all options + key type + key data + # (Excludes the comment at the end) + n = split(full_line, parts, " ") + sig = "" + for (i=1; i<=n; i++) { + sig = (sig == "" ? parts[i] : sig " " parts[i]) + # A key line is [options] [comment] + # We stop after the base64 part. Key types start with known prefixes. + if (parts[i] ~ /^(ssh-|ecdsa-|sk-)/ && i < n) { + sig = sig " " parts[i+1] + break + } + } + # Fallback if no key type found (should not happen with valid keys) + if (sig == "") sig = full_line + + if (!(sig in keys)) { + keys[sig] = full_line + order[++count] = sig + } + sources[sig] = (sources[sig] ? sources[sig] ", " : "") source +} +END { + for (i=1; i<=count; i++) { + sig = order[i] + print "# Source: " sources[sig] + print keys[sig] + } +}') + +MANUAL_KEYS="" +if [[ -f "${TARGET_FILE}" ]]; then + MANUAL_KEYS=$(awk -v begin="${BEGIN_MARKER}" -v end="${END_MARKER}" ' + BEGIN { inside=0 } + $0 == begin { inside=1; next } + $0 == end { inside=0; next } + !inside { print $0 } + ' "${TARGET_FILE}") +fi + +MANAGED_SIGS_TMP=$(mktemp) +echo "${MANAGED_BLOCK}" | awk '/^[^#]/ { + n = split($0, parts, " ") + sig = "" + for (i=1; i<=n; i++) { + sig = (sig == "" ? parts[i] : sig " " parts[i]) + if (parts[i] ~ /^(ssh-|ecdsa-|sk-)/ && i < n) { + sig = sig " " parts[i+1] + break + } + } + if (sig != "") print sig +}' > "${MANAGED_SIGS_TMP}" + +FINAL_MANUAL_KEYS=$(echo "${MANUAL_KEYS}" | awk -v sigs_file="${MANAGED_SIGS_TMP}" ' +BEGIN { + while ((getline line < sigs_file) > 0) { + managed[line] = 1 + } + close(sigs_file) +} +{ + if ($0 ~ /^[[:space:]]*$/ || $0 ~ /^[[:space:]]*#/) { + print $0 + next + } + n = split($0, parts, " ") + sig = "" + for (i=1; i<=n; i++) { + sig = (sig == "" ? parts[i] : sig " " parts[i]) + if (parts[i] ~ /^(ssh-|ecdsa-|sk-)/ && i < n) { + sig = sig " " parts[i+1] + break + } + } + if (!(sig in managed)) { + print $0 + } +}') +rm -f "${MANAGED_SIGS_TMP}" + +{ + if [[ -n "${MANAGED_BLOCK}" ]]; then + echo "${BEGIN_MARKER}" + echo "${MANAGED_BLOCK}" + echo "${END_MARKER}" + fi + echo "${FINAL_MANUAL_KEYS}" +} > "${TMP_FILE}" + +echo "Validating proposed changes..." +VALID=1 +while read -r line; do + [[ -z "${line}" || "${line}" =~ ^[[:space:]]*# ]] && continue + if ! echo "${line}" | ssh-keygen -l -f - >/dev/null 2>&1; then + echo "ERROR: Invalid SSH key detected: ${line}" >&2 + VALID=0 + fi +done < "${TMP_FILE}" + +if command -v authorized-keys-test >/dev/null 2>&1; then + if ! authorized-keys-test "${TMP_FILE}"; then + echo "ERROR: Proposed file failed authorized-keys-test." >&2 + VALID=0 + fi +fi + +if [[ ${VALID} -eq 0 ]]; then + echo "Validation failed. Aborting." >&2 + exit 1 +fi + +if [[ -f "${TARGET_FILE}" ]]; then + diff -u "${TARGET_FILE}" "${TMP_FILE}" || true +else + echo "Target file does not exist. Proposed content:" + cat "${TMP_FILE}" +fi + +if [[ ${DRY_RUN} -eq 1 ]]; then + echo "Dry run complete. No changes made." + exit 0 +fi + +if [[ -t 0 ]]; then + echo -n "Apply these changes to ${TARGET_FILE}? [y/N] " + read -r response +elif [[ ! -t 0 ]]; then + # Read from pipe or file if provided + if ! read -r response; then + echo "Non-interactive shell detected and no input provided. Aborting." + exit 1 + fi +fi + +if [[ "${response}" =~ ^([yY][eE][sS]|[yY])$ ]]; then + chmod 0600 "${TMP_FILE}" + mv "${TMP_FILE}" "${TARGET_FILE}" + echo "Changes applied successfully." +else + echo "Aborted." + exit 1 +fi