#!/usr/bin/env bash # Source-only helpers for k3s Secret-encryption validation. They deliberately # return classifications instead of printing service, config, or key material. _k3s_status_schema_valid() { jq -e -s ' length == 1 and (.[0] | type == "object" and (.stage | type == "string") and (.activekey | type == "string") and ((has("enable") | not) or (.enable | type == "boolean")) and ((has("hashmatch") | not) or (.hashmatch | type == "boolean")) and ((has("hasherror") | not) or (.hasherror | type == "string")) and ((has("inactivekeys") | not) or (.inactivekeys | type == "array"))) ' >/dev/null 2>&1 <<<"$1" } classify_encryption_status() { local status_json="$1" if ! _k3s_status_schema_valid "$status_json"; then printf 'invalid\n' return 0 fi if jq -e ' (has("enable") | not) and .stage == "" and .activekey == "" and (has("hasherror") | not) ' >/dev/null <<<"$status_json"; then printf 'disabled_no_config\n' return 0 fi if jq -e ' has("enable") and ((.hashmatch != true) or (has("hasherror") and .hasherror != "")) ' >/dev/null <<<"$status_json"; then printf 'hash_mismatch\n' return 0 fi if jq -e ' .enable == false and .stage == "start" and .activekey == "" and .hashmatch == true and ((has("inactivekeys") | not) or (.inactivekeys | length == 0)) ' >/dev/null <<<"$status_json"; then printf 'transition_start\n' return 0 fi if jq -e ' .enable == true and (.activekey | length > 0) and .hashmatch == true and (.stage == "start" or .stage == "reencrypt_finished") ' >/dev/null <<<"$status_json"; then printf 'enabled_stable\n' return 0 fi if jq -e '.enable == true and .hashmatch == true' >/dev/null <<<"$status_json"; then printf 'unsafe_transition\n' else printf 'invalid\n' fi } classify_encryption_provider() { local status_json="$1" local active_key if ! _k3s_status_schema_valid "$status_json"; then printf 'invalid\n' return 0 fi active_key="$(jq -r '.activekey' <<<"$status_json")" case "$active_key" in 'AES-CBC '*) [[ "${active_key#AES-CBC }" != "$active_key" && -n "${active_key#AES-CBC }" ]] && printf 'aescbc\n' || printf 'invalid\n' ;; 'XSalsa20-POLY1305 '*) [[ "${active_key#XSalsa20-POLY1305 }" != "$active_key" && -n "${active_key#XSalsa20-POLY1305 }" ]] && printf 'secretbox\n' || printf 'invalid\n' ;; *) printf 'invalid\n' ;; esac } version_supports_late_enable() { local version="$1" local minor patch [[ "$version" =~ ^v1\.([0-9]+)\.([0-9]+)\+k3s[0-9]+$ ]] || return 1 minor="${BASH_REMATCH[1]}" patch="${BASH_REMATCH[2]}" case "$minor" in 33) (( patch >= 10 )) ;; 34) (( patch >= 6 )) ;; 35) (( patch >= 3 )) ;; *) (( minor >= 36 )) ;; esac } require_exact_encryption_state() { local expected="$1" local status_json="$2" [[ "$(classify_encryption_status "$status_json")" == "$expected" ]] } _k3s_secure_regular_file() { local path="$1" local metadata [[ -f "$path" && ! -L "$path" ]] || return 1 metadata="$(stat --format='%u:%a:%F' -- "$path" 2>/dev/null)" || return 1 [[ "$metadata" == '0:600:regular file' ]] } _k3s_read_file() { local path="$1" [[ -f "$path" && ! -L "$path" ]] || return 1 command cat -- "$path" } _k3s_list_yaml_files() { local config="$1" file [[ -f "$config" && ! -L "$config" ]] && printf '%s\n' "$config" if [[ -d "${config}.d" && ! -L "${config}.d" ]]; then while IFS= read -r file; do printf '%s\n' "$file"; done < <( find "${config}.d" -maxdepth 1 -type f -name '*.yaml' -print | sort ) fi } verify_local_encryption_config_integrity() { local config_path="$1" local state_path="$2" local annotation_value="$3" local status_stage="$4" local state_value config_hash config_metadata state_metadata if ! _k3s_secure_regular_file "$config_path" || ! _k3s_secure_regular_file "$state_path"; then printf 'mismatch\n' return 1 fi [[ "$status_stage" =~ ^[a-z_]+$ ]] || { printf 'mismatch\n' return 1 } state_value="$(<"$state_path")" [[ "$(wc -c <"$state_path" | tr -d '[:space:]')" == "${#state_value}" ]] || { printf 'mismatch\n' return 1 } [[ "$state_value" =~ ^([a-z_]+)-([0-9a-f]{64})$ ]] || { printf 'mismatch\n' return 1 } config_hash="$(sha256sum -- "$config_path" | awk '{print $1}')" || { printf 'mismatch\n' return 1 } config_metadata="$(stat --format='%u:%a:%F' -- "$config_path" 2>/dev/null)" || config_metadata='' state_metadata="$(stat --format='%u:%a:%F' -- "$state_path" 2>/dev/null)" || state_metadata='' verify_local_encryption_config_integrity_evidence \ "$config_metadata" "$state_metadata" "$config_hash" "$state_value" \ "$annotation_value" "$status_stage" } verify_local_encryption_config_integrity_evidence() { local config_metadata="$1" state_metadata="$2" config_hash="$3" local state_value="$4" annotation_value="$5" status_stage="$6" expected if [[ "$config_metadata" != '0:600:regular file' || "$state_metadata" != '0:600:regular file' || ! "$config_hash" =~ ^[0-9a-f]{64}$ || ! "$status_stage" =~ ^[a-z_]+$ || ! "$state_value" =~ ^[a-z_]+-[0-9a-f]{64}$ ]]; then printf 'mismatch\n' return 1 fi expected="${status_stage}-${config_hash}" if [[ "$state_value" == "$expected" && "$annotation_value" == "$expected" ]]; then printf 'match\n' return 0 fi printf 'mismatch\n' return 1 } _k3s_systemctl_show() { systemctl show k3s \ --property=ExecStart \ --property=Environment \ --property=EnvironmentFiles 2>/dev/null } _k3s_systemd_dropin_lines() { local dropin_dir="${K3S_SYSTEMD_DROPIN_DIR:-/etc/systemd/system/k3s.service.d}" local file [[ -e "$dropin_dir" ]] || return 0 [[ -d "$dropin_dir" && ! -L "$dropin_dir" ]] || return 1 while IFS= read -r file; do [[ -r "$file" && ! -L "$file" ]] || return 1 sed -n -E '/^[[:space:]]*(Environment|EnvironmentFile)=/p' "$file" done < <(find "$dropin_dir" -maxdepth 1 -type f -name '*.conf' -print | sort) } _k3s_is_dynamic_value() { local value="$1" [[ "$value" == *'$'* || "$value" == *'`'* || "$value" == *'\\'* ]] } # Strict, non-evaluating readers for systemd's already-resolved show output and # EnvironmentFile syntax. Unsupported quoting, resets, or expansions fail # closed instead of being interpreted by a shell. _k3s_plain_value() { local value="$1" quote="'" [[ -n "$value" && "$value" != *' '* && "$value" != *'"'* && "$value" != *"$quote"* && "$value" != *'$'* && "$value" != *'`'* && "$value" != *'\\'* ]] } _k3s_safe_payload() { local value="$1" quote="'" [[ -n "$value" && "$value" != *'"'* && "$value" != *"$quote"* && "$value" != *'$'* && "$value" != *'`'* && "$value" != *'\\'* ]] } _k3s_effective_service_show() { local show dropins line has_environment=false show="$(_k3s_systemctl_show)" || return 2 while IFS= read -r line; do [[ "$line" == Environment=* || "$line" == EnvironmentFiles=* ]] || continue [[ -n "${line#*=}" ]] && has_environment=true done <<<"$show" if ! "$has_environment"; then dropins="$(_k3s_systemd_dropin_lines)" || return 2 [[ -z "$dropins" ]] || show+=$'\n'"$dropins" fi printf '%s\n' "$show" } _k3s_envfile_values() { local file="$1" key="$2" line trimmed name value text char quote='' content local in_record=false target_record=false continued=false local index length content="$(_k3s_read_file "$file")" || return 2 while IFS= read -r line || [[ -n "$line" ]]; do if ! "$in_record"; then trimmed="${line#"${line%%[![:space:]]*}"}" [[ -z "$trimmed" || "$trimmed" == \#* || "$trimmed" == \;* ]] && continue [[ "$trimmed" == *=* ]] || { [[ "$trimmed" == "$key" ]] && return 2; continue; } name="${trimmed%%=*}" value="${trimmed#*=}" target_record=false [[ "$name" == "$key" ]] && target_record=true text="$value" quote='' else text="$line" fi continued=false length=${#text} for ((index=0; index= 2 )) || return 2 [[ "${words[0]##*/}" == k3s && "${words[1]}" == server ]] || return 2 for ((index=0; index<${#words[@]}; index++)); do token="${words[index]}" case "$token" in "${option}" ) if [[ "$option" == '--secrets-encryption' ]]; then printf 'true\n'; elif (( index + 1 < ${#words[@]} )); then option_value="${words[index + 1]}"; _k3s_plain_value "$option_value" || return 2; printf '%s\n' "$option_value"; else return 2; fi ;; "${option}"=*) option_value="${token#*=}"; _k3s_plain_value "$option_value" || return 2; printf '%s\n' "$option_value" ;; esac done done <<<"$show" (( exec_count == 1 )) || return 2 return 0 } _k3s_unique_value() { local values="$1" value count=0 chosen='' while IFS= read -r value; do [[ -n "$value" ]] || continue ((count++)); chosen="$value" done <<<"$values" (( count <= 1 )) || return 2 [[ -n "$chosen" ]] && printf '%s\n' "$chosen" return 0 } _k3s_selected_config() { local show="$1" cli env default_root raw raw="$(_k3s_cli_values "$show" --config)" || return 2 cli="$(_k3s_unique_value "$raw")" || return 2 raw="$(_k3s_service_env_values "$show" K3S_CONFIG_FILE)" || return 2 env="$(_k3s_unique_value "$raw")" || return 2 if _k3s_is_dynamic_value "$cli" || _k3s_is_dynamic_value "$env"; then return 2 fi default_root="${K3S_CONFIG_DIR:-/etc/rancher/k3s}" if [[ -n "$cli" ]]; then printf '%s\n' "$cli"; elif [[ -n "$env" ]]; then printf '%s\n' "$env"; else printf '%s\n' "${default_root}/config.yaml"; fi } _k3s_yaml_value() { local config="$1" key="$2" file line raw value selected='' selected_file='' content files files="$(_k3s_list_yaml_files "$config")" || return 2 while IFS= read -r file; do [[ -n "$file" ]] || continue content="$(_k3s_read_file "$file")" || return 2 raw='' while IFS= read -r line || [[ -n "$line" ]]; do line="${line#"${line%%[![:space:]]*}"}" [[ "$line" == "${key}:"* ]] || continue value="${line#"${key}:"}" value="$(sed -E 's/[[:space:]]+#.*$//; s/^[[:space:]]+//; s/[[:space:]]+$//;' <<<"$value")" _k3s_plain_value "$value" || return 2 [[ -z "$raw" || "$raw" == "$value" ]] || return 2 raw="$value" done <<<"$content" if [[ -n "$raw" ]]; then selected="$raw"; selected_file="$file"; fi done <<<"$files" [[ -n "$selected" ]] && printf '%s|%s\n' "$selected_file" "$selected" return 0 } _k3s_resolve_key() { local show="$1" cli_option="$2" env_key="$3" yaml_key="$4" cli env config yaml raw raw="$(_k3s_cli_values "$show" "$cli_option")" || return 2 cli="$(_k3s_unique_value "$raw")" || return 2 [[ -z "$cli" ]] || { printf 'command-line|%s\n' "$cli"; return 0; } raw="$(_k3s_service_env_values "$show" "$env_key")" || return 2 env="$(_k3s_unique_value "$raw")" || return 2 [[ -z "$env" ]] || { printf 'environment|%s\n' "$env"; return 0; } config="$(_k3s_selected_config "$show")" || return 2 yaml="$(_k3s_yaml_value "$config" "$yaml_key")" || return 2 [[ -z "$yaml" ]] || printf '%s\n' "$yaml" } detect_effective_encryption_config_owner() { local show enable provider enable_source enable_value provider_source provider_value show="$(_k3s_effective_service_show)" || { printf 'ambiguous\n'; return 0; } enable="$(_k3s_resolve_key "$show" --secrets-encryption K3S_SECRETS_ENCRYPTION secrets-encryption)" || { printf 'ambiguous\n'; return 0; } provider="$(_k3s_resolve_key "$show" --secrets-encryption-provider K3S_SECRETS_ENCRYPTION_PROVIDER secrets-encryption-provider)" || { printf 'ambiguous\n'; return 0; } [[ -n "$enable" ]] || { printf 'ambiguous\n'; return 0; } IFS='|' read -r enable_source enable_value <<<"$enable" [[ "$enable_value" == true ]] || { printf 'ambiguous\n'; return 0; } if [[ -n "$provider" ]]; then IFS='|' read -r provider_source provider_value <<<"$provider"; [[ "$provider_value" == aescbc || "$provider_value" == secretbox ]] || { printf 'ambiguous\n'; return 0; }; printf '%s/%s\n' "$provider_value" "$provider_source"; else printf 'aescbc/implicit-default\n'; fi } _k3s_datastore_local_evidence() { local data_dir="$1" local state_db="${data_dir}/server/db/state.db" local etcd_dir="${data_dir}/server/db/etcd" if [[ -f "$state_db" && -d "$etcd_dir" ]]; then printf 'ambiguous\n'; elif [[ -f "$state_db" ]]; then printf 'sqlite\n'; elif [[ -d "$etcd_dir" ]]; then printf 'embedded-etcd\n'; else printf 'none\n'; fi } detect_k3s_datastore() { local show data endpoint source value evidence show="$(_k3s_effective_service_show)" || { printf 'ambiguous\n'; return 0; } data="$(_k3s_resolve_key "$show" --data-dir K3S_DATA_DIR data-dir)" || { printf 'ambiguous\n'; return 0; } endpoint="$(_k3s_resolve_key "$show" --datastore-endpoint K3S_DATASTORE_ENDPOINT datastore-endpoint)" || { printf 'ambiguous\n'; return 0; } if [[ -n "$data" ]]; then IFS='|' read -r source value <<<"$data"; data="$value"; else source=default; data=/var/lib/rancher/k3s; fi _k3s_is_dynamic_value "$data" || [[ "$data" == /* ]] || { printf 'ambiguous\n'; return 0; } [[ "$source" == default || "$data" == /var/lib/rancher/k3s ]] || { printf 'ambiguous\n'; return 0; } evidence="$(_k3s_datastore_local_evidence "$data")" || { printf 'ambiguous\n'; return 0; } if [[ -n "$endpoint" ]]; then [[ "$evidence" == none ]] && printf 'external\n' || printf 'ambiguous\n'; elif [[ "$evidence" == sqlite || "$evidence" == embedded-etcd ]]; then printf '%s\n' "$evidence"; else printf 'ambiguous\n'; fi } _k3s_now_seconds() { printf '%s\n' "$SECONDS" } _k3s_timeout_before_deadline() { local deadline="$1" now remaining command_timeout=9 now="$(_k3s_now_seconds)" || return 1 [[ "$now" =~ ^[0-9]+$ ]] || return 1 remaining=$((deadline - now)) (( remaining > 1 )) || return 1 (( command_timeout < remaining )) || command_timeout=$((remaining - 1)) printf '%s\n' "$command_timeout" } _k3s_sleep_until_poll() { local deadline="$1" next_poll="$2" now delay remaining now="$(_k3s_now_seconds)" || return 1 [[ "$now" =~ ^[0-9]+$ ]] || return 1 (( now < deadline )) || return 1 delay=$((next_poll - now)) (( delay > 0 )) || return 0 remaining=$((deadline - now)) (( delay < remaining )) || delay="$remaining" sleep "$delay" } _k3s_read_encryption_status() { local command_timeout="$1" [[ "$command_timeout" =~ ^[1-9]$ ]] || return 1 timeout --signal=TERM --kill-after=1s "${command_timeout}s" \ sudo --non-interactive k3s secrets-encrypt status --output json 2>/dev/null } wait_for_k3s_api() { local attempt started_at deadline next_poll command_timeout started_at="$(_k3s_now_seconds)" || return 1 [[ "$started_at" =~ ^[0-9]+$ ]] || return 1 deadline=$((started_at + 600)) for ((attempt=1; attempt<=60; attempt++)); do command_timeout="$(_k3s_timeout_before_deadline "$deadline")" || return 1 if timeout --signal=TERM --kill-after=1s "${command_timeout}s" \ sudo --non-interactive k3s kubectl get --raw=/readyz >/dev/null 2>&1; then return 0 fi (( attempt < 60 )) || break next_poll=$((started_at + attempt * 10)) _k3s_sleep_until_poll "$deadline" "$next_poll" || return 1 done return 1 } wait_for_reencrypt_finished() { local rotate_rc="$1" local attempt status_json status_class stage started_at deadline next_poll command_timeout # rotate-keys can return non-zero after the server accepted the operation; # a later authoritative finished status is therefore allowed to succeed. [[ "$rotate_rc" =~ ^[0-9]+$ ]] || return 1 started_at="$(_k3s_now_seconds)" || return 1 [[ "$started_at" =~ ^[0-9]+$ ]] || return 1 deadline=$((started_at + 600)) for ((attempt=1; attempt<=60; attempt++)); do command_timeout="$(_k3s_timeout_before_deadline "$deadline")" || return 1 status_json="$(_k3s_read_encryption_status "$command_timeout")" || return 1 status_class="$(classify_encryption_status "$status_json")" [[ "$status_class" != hash_mismatch && "$status_class" != invalid ]] || return 1 stage="$(jq -r '.stage' <<<"$status_json" 2>/dev/null)" || return 1 [[ "$stage" != start ]] || return 1 [[ "$stage" == reencrypt_finished ]] && return 0 [[ "$stage" == reencrypt_active ]] || return 1 (( attempt < 60 )) || break next_poll=$((started_at + attempt * 10)) _k3s_sleep_until_poll "$deadline" "$next_poll" || return 1 done return 1 }