#!/usr/bin/env bash # Read-only k3s Secret-encryption inventory. Privileged execution is limited to # bounded invocations of validated, root-owned system binaries; workspace code # is never executed or sourced by sudo. set -Eeuo pipefail readonly KSE_SECURE_PATH='/usr/sbin:/usr/bin:/sbin:/bin' readonly KSE_EXPECTED_VERSION='v1.36.2+k3s1' readonly KSE_EXPECTED_NODE='donghyeon-system-product-name' readonly KSE_K3S='/usr/local/bin/k3s' readonly KSE_TIMEOUT='/usr/bin/timeout' readonly KSE_SUDO='/usr/bin/sudo' readonly KSE_SYSTEMCTL='/usr/bin/systemctl' readonly KSE_STAT='/usr/bin/stat' readonly KSE_SHA256SUM='/usr/bin/sha256sum' readonly KSE_CAT='/usr/bin/cat' readonly KSE_JQ='/usr/bin/jq' readonly KSE_FIND='/usr/bin/find' readonly KSE_ID='/usr/bin/id' readonly KSE_TEST='/usr/bin/test' if [[ "${BASH_SOURCE[0]}" == */* ]]; then readonly KSE_SCRIPT_DIR="${BASH_SOURCE[0]%/*}" else readonly KSE_SCRIPT_DIR='.' fi readonly KSE_ROOT="$(cd -- "${KSE_SCRIPT_DIR}/../.." && pwd -P)" readonly KSE_LIBRARY_PATH="${KSE_ROOT}/scripts/lib/k3s-secret-encryption.sh" readonly KSE_CONFIG_PATH='/var/lib/rancher/k3s/server/cred/encryption-config.json' readonly KSE_STATE_PATH='/var/lib/rancher/k3s/server/cred/encryption-state.json' kse_fail() { printf 'ERROR: %s\n' "$*" >&2 return 1 } kse_usage() { cat <<'USAGE' 사용법: bash scripts/validate/k3s-secret-encryption.sh [기대 상태] [--verified-output-dir DIR] 기대 상태(하나만 지정): --expect-disabled --expect-transition-start --expect-enabled --expect-reencrypted --expect-reencrypted-restore # 격리 복구 host의 다른 node 이름 허용 USAGE } _kse_trusted_executable() { local path="$1" metadata uid mode file_type [[ -f "$path" && ! -L "$path" && -x "$path" ]] || return 1 metadata="$($KSE_STAT --format='%u:%a:%F' -- "$path" 2>/dev/null)" || return 1 IFS=: read -r uid mode file_type <<<"$metadata" [[ "$uid" == 0 && "$mode" =~ ^[0-7]{3,4}$ && "$file_type" == 'regular file' ]] || return 1 (( (8#$mode & 8#022) == 0 )) } _kse_validate_runtime_dependencies() { local binary for binary in \ "$KSE_TIMEOUT" "$KSE_SUDO" "$KSE_K3S" "$KSE_SYSTEMCTL" \ "$KSE_STAT" "$KSE_SHA256SUM" "$KSE_CAT" "$KSE_JQ" \ "$KSE_FIND" "$KSE_ID" "$KSE_TEST" \ /usr/bin/sed /usr/bin/sort; do _kse_trusted_executable "$binary" || return 1 done } _kse_privileged_capture() { local binary="$1" shift "$KSE_TIMEOUT" --signal=TERM --kill-after=1s 9s \ "$KSE_SUDO" --non-interactive -- "$binary" "$@" 2>/dev/null } _kse_validate_sudo_credentials() { "$KSE_TIMEOUT" --signal=TERM --kill-after=1s 9s \ "$KSE_SUDO" --non-interactive --validate >/dev/null 2>&1 } _kse_privileged_quiet() { _kse_privileged_capture "$@" >/dev/null 2>&1 } _kse_read_version() { local raw version raw="$(_kse_privileged_capture "$KSE_K3S" --version)" || return 1 version="$(/usr/bin/sed -n -E 's/^k3s version (v[0-9]+\.[0-9]+\.[0-9]+\+k3s[0-9]+)([[:space:]].*)?$/\1/p' <<<"$raw")" [[ "$version" == "$KSE_EXPECTED_VERSION" ]] || return 1 printf '%s\n' "$version" } _kse_read_status() { _kse_privileged_capture "$KSE_K3S" secrets-encrypt status --output json } _kse_read_nodes() { _kse_privileged_capture "$KSE_K3S" kubectl get nodes -o json } _kse_read_systemctl_show() { _kse_privileged_capture "$KSE_SYSTEMCTL" show k3s \ --property=ExecStart --property=Environment --property=EnvironmentFiles } _kse_api_ready() { _kse_privileged_quiet "$KSE_K3S" kubectl get --raw=/readyz } _kse_allowed_root_text_path() { local path="$1" [[ "$path" == /etc/rancher/k3s/config.yaml || "$path" == /etc/systemd/system/k3s.service.env || "$path" =~ ^/etc/rancher/k3s/config\.yaml\.d/[A-Za-z0-9._-]+\.yaml$ ]] } _kse_read_root_text() { local path="$1" metadata uid mode _kse_allowed_root_text_path "$path" || return 1 _kse_privileged_quiet "$KSE_TEST" -f "$path" || return 1 _kse_privileged_quiet "$KSE_TEST" ! -L "$path" || return 1 metadata="$(_kse_privileged_capture "$KSE_STAT" --format='%u:%a' -- "$path")" || return 1 IFS=: read -r uid mode <<<"$metadata" [[ "$uid" == 0 && "$mode" =~ ^[0-7]{3,4}$ ]] || return 1 (( (8#$mode & 8#022) == 0 )) || return 1 _kse_privileged_capture "$KSE_CAT" -- "$path" } _kse_root_path_kind() { local path="$1" if _kse_privileged_quiet "$KSE_TEST" -f "$path"; then printf 'file\n' elif _kse_privileged_quiet "$KSE_TEST" -d "$path"; then printf 'directory\n' else printf 'absent\n' fi } _kse_detect_datastore() { local service_show="$1" _k3s_systemctl_show() { printf '%s\n' "$service_show"; } _k3s_systemd_dropin_lines() { return 0; } _k3s_read_file() { _kse_read_root_text "$1"; } _k3s_list_yaml_files() { local config="$1" file listing='' [[ "$config" == /etc/rancher/k3s/config.yaml ]] || return 2 if _kse_privileged_quiet "$KSE_TEST" -f "$config"; then printf '%s\n' "$config"; fi if _kse_privileged_quiet "$KSE_TEST" -d "${config}.d"; then listing="$(_kse_privileged_capture "$KSE_FIND" "${config}.d" -maxdepth 1 -type f -name '*.yaml' -print)" || return 2 while IFS= read -r file; do [[ -z "$file" ]] || _kse_allowed_root_text_path "$file" || return 2 done <<<"$listing" [[ -z "$listing" ]] || printf '%s\n' "$listing" | /usr/bin/sort fi } _k3s_datastore_local_evidence() { local data_dir="$1" state_kind etcd_kind [[ "$data_dir" == /var/lib/rancher/k3s ]] || { printf 'ambiguous\n'; return 0; } state_kind="$(_kse_root_path_kind "${data_dir}/server/db/state.db")" || return 1 etcd_kind="$(_kse_root_path_kind "${data_dir}/server/db/etcd")" || return 1 if [[ "$state_kind" == file && "$etcd_kind" == directory ]]; then printf 'ambiguous\n'; elif [[ "$state_kind" == file ]]; then printf 'sqlite\n'; elif [[ "$etcd_kind" == directory ]]; then printf 'embedded-etcd\n'; else printf 'none\n'; fi } detect_k3s_datastore } _kse_root_metadata() { local path="$1" metadata metadata="$(_kse_privileged_capture "$KSE_STAT" --format='%u:%a:%F' -- "$path")" || return 1 [[ "$metadata" == '0:600:regular file' ]] || return 1 printf '%s\n' "$metadata" } _kse_integrity_from_root_evidence() { local annotation="$1" stage="$2" config_metadata state_metadata local config_hash_line config_hash state_with_sentinel state_value result config_metadata="$(_kse_root_metadata "$KSE_CONFIG_PATH")" || { printf 'mismatch\n'; return 1; } state_metadata="$(_kse_root_metadata "$KSE_STATE_PATH")" || { printf 'mismatch\n'; return 1; } config_hash_line="$(_kse_privileged_capture "$KSE_SHA256SUM" -- "$KSE_CONFIG_PATH")" || { printf 'mismatch\n'; return 1; } config_hash="${config_hash_line%% *}" [[ "$config_hash_line" == "${config_hash} ${KSE_CONFIG_PATH}" && "$config_hash" =~ ^[0-9a-f]{64}$ ]] || { printf 'mismatch\n'; return 1; } state_with_sentinel="$(_kse_privileged_capture "$KSE_CAT" -- "$KSE_STATE_PATH"; printf .)" || { printf 'mismatch\n'; return 1; } state_value="${state_with_sentinel%.}" result="$(verify_local_encryption_config_integrity_evidence \ "$config_metadata" "$state_metadata" "$config_hash" "$state_value" \ "$annotation" "$stage" 2>/dev/null || true)" [[ "$result" == match ]] || { printf 'mismatch\n'; return 1; } printf 'match\n' } _kse_validate_output_dir() { local output_dir="$1" physical metadata uid [[ "$output_dir" =~ ^/tmp/platform-k3s-encryption\.[A-Za-z0-9]+$ ]] || return 1 [[ -d /tmp && ! -L /tmp && -d "$output_dir" && ! -L "$output_dir" ]] || return 1 [[ "$(cd -P -- /tmp && pwd -P)" == /tmp ]] || return 1 physical="$(cd -P -- "$output_dir" && pwd -P)" || return 1 [[ "$physical" == "$output_dir" ]] || return 1 uid="$($KSE_ID -u)" || return 1 metadata="$($KSE_STAT --format='%u:%a:%F' -- "$output_dir" 2>/dev/null)" || return 1 [[ "$metadata" == "${uid}:700:directory" ]] || return 1 [[ -z "$($KSE_FIND "$output_dir" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]] } _kse_write_verified_output() { local output_dir="$1" invoking_uid metadata physical entry local -a entries=() local directory_fd _kse_validate_output_dir "$output_dir" || return 1 invoking_uid="$($KSE_ID -u)" || return 1 exec {directory_fd}<"$output_dir" || return 1 physical="$(cd -P -- "/proc/self/fd/${directory_fd}" && pwd -P)" || { exec {directory_fd}<&-; return 1; } [[ "$physical" == "$output_dir" ]] || { exec {directory_fd}<&-; return 1; } metadata="$($KSE_STAT -L --format='%u:%a:%F' -- "/proc/self/fd/${directory_fd}" 2>/dev/null)" || { exec {directory_fd}<&-; return 1; } [[ "$metadata" == "${invoking_uid}:700:directory" ]] || { exec {directory_fd}<&-; return 1; } ( umask 077 set -C printf 'version=%s\nserver_count=%s\nnode_name=%s\nnode_uid=%s\nnode_ready=%s\ndatastore=%s\nencryption=%s\nrotation=%s\nserver_hashes=%s\nintegrity=%s\napi_readyz=%s\n' \ "$version" "$server_count" "$node_name" "$node_uid" "$node_ready" "$datastore" "$encryption" "$rotation" "$server_hashes" "$integrity" "$api_readyz" \ >"/proc/self/fd/${directory_fd}/inventory.env" printf '%s\n' "$status_sha256" >"/proc/self/fd/${directory_fd}/status.sha256" ) || { exec {directory_fd}<&-; return 1; } metadata="$($KSE_STAT --format='%u:%a:%F' -- "/proc/self/fd/${directory_fd}/inventory.env" "/proc/self/fd/${directory_fd}/status.sha256" 2>/dev/null)" || { exec {directory_fd}<&-; return 1; } [[ "$metadata" == "${invoking_uid}:600:regular file"$'\n'"${invoking_uid}:600:regular file" ]] || { exec {directory_fd}<&-; return 1; } while IFS= read -r -d '' entry; do entries+=("${entry##*/}"); done < <( "$KSE_FIND" -H "/proc/self/fd/${directory_fd}" -mindepth 1 -maxdepth 1 -print0 2>/dev/null ) exec {directory_fd}<&- (( ${#entries[@]} == 2 )) || return 1 [[ " ${entries[*]} " == *' inventory.env '* && " ${entries[*]} " == *' status.sha256 '* ]] } k3s_secret_encryption_main() { local expectation='' output_dir='' output_dir_seen=false local raw_status canonical_status status_class stage status_hash_line local nodes_json service_show annotations='' annotation='' first_annotation='' local version server_count node_name node_uid node_ready datastore encryption rotation local server_hashes=\mismatch integrity=mismatch api_readyz status_sha256 PATH="$KSE_SECURE_PATH" export PATH unset K3S_CONFIG_DIR K3S_SYSTEMD_DROPIN_DIR while (( $# > 0 )); do case "$1" in --expect-disabled|--expect-transition-start|--expect-enabled|--expect-reencrypted|--expect-reencrypted-restore) [[ -z "$expectation" ]] || { kse_usage >&2; return 2; } expectation="$1" ;; --verified-output-dir) (( $# >= 2 )) || { kse_usage >&2; return 2; } ! "$output_dir_seen" && [[ -n "$2" ]] || { kse_usage >&2; return 2; } output_dir="$2"; output_dir_seen=true; shift ;; --help|-h) kse_usage; return 0 ;; *) kse_usage >&2; return 2 ;; esac shift done _kse_validate_runtime_dependencies || { kse_fail 'trusted runtime dependency validation failed'; return 1; } ! "$output_dir_seen" || _kse_validate_output_dir "$output_dir" || { kse_fail 'verified output directory is unsafe'; return 1; } _kse_validate_sudo_credentials || { kse_fail 'noninteractive sudo authorization is unavailable; run sudo -v in the same terminal, then rerun the validator' return 1 } # shellcheck source=/dev/null source "$KSE_LIBRARY_PATH" version="$(_kse_read_version)" || { kse_fail 'k3s version is not the required production version'; return 1; } raw_status="$(_kse_read_status)" || { kse_fail 'read-only encryption status failed'; return 1; } canonical_status="$($KSE_JQ -ceS . <<<"$raw_status" 2>/dev/null)" || { kse_fail 'encryption status is invalid'; return 1; } status_class="$(classify_encryption_status "$canonical_status")" [[ "$status_class" != invalid ]] || { kse_fail 'encryption status is invalid'; return 1; } status_hash_line="$(printf '%s' "$canonical_status" | "$KSE_SHA256SUM")" || return 1 status_sha256="${status_hash_line%% *}" [[ "$status_sha256" =~ ^[0-9a-f]{64}$ ]] || return 1 encryption=Disabled; rotation=unsafe case "$status_class" in disabled_no_config) rotation=none ;; transition_start) rotation=start ;; enabled_stable) encryption=Enabled stage="$($KSE_JQ -er '.stage' <<<"$canonical_status")" || return 1 [[ "$stage" == start || "$stage" == reencrypt_finished ]] || return 1 rotation="$stage" ;; unsafe_transition|hash_mismatch) encryption=Enabled ;; *) return 1 ;; esac nodes_json="$(_kse_read_nodes)" || { kse_fail 'read-only node inventory failed'; return 1; } server_count="$($KSE_JQ -er '[.items[] | select((.metadata.labels["node-role.kubernetes.io/control-plane"]? != null) or (.metadata.labels["node-role.kubernetes.io/master"]? != null))] | length' <<<"$nodes_json" 2>/dev/null)" || return 1 [[ "$server_count" == 1 ]] || { kse_fail 'server count is not exactly one'; return 1; } node_name="$($KSE_JQ -er '[.items[] | select((.metadata.labels["node-role.kubernetes.io/control-plane"]? != null) or (.metadata.labels["node-role.kubernetes.io/master"]? != null))][0].metadata.name' <<<"$nodes_json" 2>/dev/null)" || return 1 node_uid="$($KSE_JQ -er '[.items[] | select((.metadata.labels["node-role.kubernetes.io/control-plane"]? != null) or (.metadata.labels["node-role.kubernetes.io/master"]? != null))][0].metadata.uid | if type == "string" and test("^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") then . else error("invalid node uid") end' <<<"$nodes_json" 2>/dev/null)" || { kse_fail 'server node UID is invalid'; return 1; } if [[ "$expectation" != --expect-reencrypted-restore ]]; then [[ "$node_name" == "$KSE_EXPECTED_NODE" ]] || { kse_fail 'server node identity mismatch'; return 1; } fi node_ready="$($KSE_JQ -er '[.items[] | select(.metadata.name == $name)][0].status.conditions | [ .[] | select(.type == "Ready") ] | if length == 1 and .[0].status == "True" then "Ready" else error("not ready") end' --arg name "$node_name" <<<"$nodes_json" 2>/dev/null)" || { kse_fail 'server node is not Ready'; return 1; } annotations="$($KSE_JQ -er '[.items[] | select((.metadata.labels["node-role.kubernetes.io/control-plane"]? != null) or (.metadata.labels["node-role.kubernetes.io/master"]? != null)) | .metadata.annotations["k3s.io/encryption-config-hash"]?] | if length > 0 and all(.[]; type == "string" and test("^[a-z_]+-[0-9a-f]{64}$")) then .[] else empty end' <<<"$nodes_json" 2>/dev/null)" || annotations='' if [[ -n "$annotations" ]]; then first_annotation="${annotations%%$'\n'*}"; annotation="$first_annotation" while IFS= read -r annotation; do [[ "$annotation" == "$first_annotation" ]] || first_annotation=''; done <<<"$annotations" [[ -z "$first_annotation" ]] || server_hashes=match fi service_show="$(_kse_read_systemctl_show)" || { kse_fail 'read-only systemd inventory failed'; return 1; } datastore="$(_kse_detect_datastore "$service_show")" || datastore=ambiguous case "$datastore" in sqlite|embedded-etcd|external|ambiguous) ;; *) datastore=ambiguous ;; esac if [[ "$server_hashes" == match && ( "$status_class" == transition_start || "$status_class" == enabled_stable ) ]]; then integrity="$(_kse_integrity_from_root_evidence "$first_annotation" "$rotation" 2>/dev/null || true)" [[ "$integrity" == match ]] || integrity=mismatch fi if _kse_api_ready; then api_readyz=pass; else api_readyz=fail; fi printf 'K3s version: %s\nServer count: %s\nNode: %s %s\nDatastore: %s\nEncryption status: %s\nRotation stage: %s\nServer annotation hashes: %s\nLocal config/state/annotation integrity: %s\nAPI readyz: %s\n' \ "$version" "$server_count" "$node_name" "$node_ready" "$datastore" "$encryption" "$rotation" "$server_hashes" "$integrity" "$api_readyz" [[ "$api_readyz" == pass ]] || return 1 case "$expectation" in '') [[ "$status_class" != unsafe_transition && "$status_class" != hash_mismatch ]] || return 1 ;; --expect-disabled) [[ "$status_class" == disabled_no_config ]] || return 1 ;; --expect-transition-start) [[ "$status_class" == transition_start ]] || return 1 ;; --expect-enabled) [[ "$status_class" == enabled_stable ]] || return 1 ;; --expect-reencrypted|--expect-reencrypted-restore) [[ "$status_class" == enabled_stable && "$rotation" == reencrypt_finished && "$server_hashes" == match && "$integrity" == match ]] || return 1 ;; esac ! "$output_dir_seen" || _kse_write_verified_output "$output_dir" || { kse_fail 'verified output write failed'; return 1; } } if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then k3s_secret_encryption_main "$@" fi