Add private Home Assistant credentials settings
This commit is contained in:
+188
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
helper="$repo_dir/config/dot/quickshell/scripts/panama-home-assistant-config"
|
||||
service="$repo_dir/config/dot/quickshell/services/HomeAssistantConfig.qml"
|
||||
page="$repo_dir/config/dot/quickshell/modules/settings/HomePhonePage.qml"
|
||||
harness_fixture="$repo_dir/tests/quickshell/HomeAssistantConfigHarness.qml"
|
||||
work="$(mktemp -d /tmp/panama-ha-config.XXXXXX)"
|
||||
env_file="$work/env"
|
||||
|
||||
fail() {
|
||||
printf 'Home Assistant config contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if declare -F qs_for_test >/dev/null; then
|
||||
qs_for_test kill >/dev/null 2>&1 || true
|
||||
fi
|
||||
rm -rf "$work"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
[[ -x "$helper" ]] || fail 'credential helper is missing or not executable'
|
||||
[[ -f "$service" ]] || fail 'credential service is missing'
|
||||
[[ -f "$harness_fixture" ]] || fail 'credential runtime harness is missing'
|
||||
|
||||
rg -Fq 'stdinEnabled: true' "$service" || fail 'credential writes do not use process stdin'
|
||||
rg -Fq 'writeProc.write(root.pendingPayload + "\n")' "$service" || fail 'credential payload is not written over stdin'
|
||||
rg -Fq 'root.pendingPayload = ""' "$service" || fail 'credential payload remains in service memory after write'
|
||||
if rg -q 'command:.*(token|pendingPayload)' "$service"; then
|
||||
fail 'credential data can reach a process command line'
|
||||
fi
|
||||
rg -Fq 'PasswordField {' "$page" || fail 'Home Assistant token is not entered through the masked field'
|
||||
rg -Fq 'HomeAssistantConfig.save(' "$page" || fail 'Home Assistant configuration cannot be saved from Settings'
|
||||
rg -Fq 'HomeAssistantConfig.clearToken()' "$page" || fail 'stored Home Assistant token cannot be cleared'
|
||||
|
||||
cat >"$env_file" <<'EOF'
|
||||
# Existing private shell settings must survive byte-for-byte.
|
||||
export KEEP_ME='untouched value'
|
||||
export JIRA_CREDENTIALS='unrelated-secret'
|
||||
export PANAMA_HOME_ASSISTANT_URL='https://old.example.test'
|
||||
export PANAMA_HOME_ASSISTANT_TOKEN='old-token'
|
||||
export PANAMA_HOME_ASSISTANT_ENTITIES='light.old'
|
||||
EOF
|
||||
chmod 0644 "$env_file"
|
||||
|
||||
run_helper() {
|
||||
PANAMA_HOME_ASSISTANT_ENV_FILE="$env_file" "$helper" "$@"
|
||||
}
|
||||
|
||||
status="$(run_helper status)" || fail 'status failed for a valid private env file'
|
||||
jq -e '.configured == true and .tokenConfigured == true
|
||||
and .url == "https://old.example.test"
|
||||
and .entities == ["light.old"] and (has("token") | not)' \
|
||||
<<<"$status" >/dev/null || fail "status exposed or misread credentials: $status"
|
||||
if rg -q 'old-token|unrelated-secret' <<<"$status"; then
|
||||
fail 'status output leaked a secret'
|
||||
fi
|
||||
|
||||
secret='ha-secret-must-never-appear-in-ps-or-output'
|
||||
payload="$work/payload.json"
|
||||
jq -cn --arg token "$secret" '{
|
||||
url: "https://home.example.test/",
|
||||
token: $token,
|
||||
entities: ["light.kitchen", "light.desk", "light.kitchen"]
|
||||
}' >"$payload"
|
||||
|
||||
# Keep stdin open long enough to prove the token is absent from the helper's
|
||||
# process arguments. The secret lives only in the private payload file/stdin.
|
||||
fifo="$work/input.fifo"
|
||||
mkfifo "$fifo"
|
||||
PANAMA_HOME_ASSISTANT_ENV_FILE="$env_file" "$helper" write <"$fifo" >"$work/write.out" 2>"$work/write.err" &
|
||||
helper_pid=$!
|
||||
for _ in $(seq 1 30); do
|
||||
kill -0 "$helper_pid" 2>/dev/null && break
|
||||
sleep 0.05
|
||||
done
|
||||
if ps -o args= -p "$helper_pid" | rg -Fq "$secret"; then
|
||||
fail 'token appeared in the credential helper process arguments'
|
||||
fi
|
||||
cp "$payload" "$fifo"
|
||||
wait "$helper_pid" || fail 'stdin credential write failed'
|
||||
|
||||
write_result="$(cat "$work/write.out")"
|
||||
jq -e '.ok == true and .configured == true and .tokenConfigured == true
|
||||
and .url == "https://home.example.test"
|
||||
and .entities == ["light.kitchen", "light.desk"] and (has("token") | not)' \
|
||||
<<<"$write_result" >/dev/null || fail "write returned unsafe or incorrect state: $write_result"
|
||||
if rg -q "$secret|old-token|unrelated-secret" "$work/write.out" "$work/write.err"; then
|
||||
fail 'credential helper output leaked a secret'
|
||||
fi
|
||||
|
||||
[[ "$(stat -c '%a' "$env_file")" == "600" ]] || fail 'private env file is not mode 0600'
|
||||
rg -Fxq "export KEEP_ME='untouched value'" "$env_file" || fail 'unrelated env content changed'
|
||||
rg -Fxq "export JIRA_CREDENTIALS='unrelated-secret'" "$env_file" || fail 'unrelated secret changed'
|
||||
rg -Fq "$secret" "$env_file" || fail 'new token was not stored'
|
||||
|
||||
# Omitting token preserves it; an explicit empty token clears it.
|
||||
printf '%s\n' '{"url":"https://new.example.test","entities":"light.office, light.hall"}' \
|
||||
| run_helper write >/dev/null || fail 'non-secret update failed'
|
||||
rg -Fq "$secret" "$env_file" || fail 'blank token field unexpectedly erased the stored token'
|
||||
|
||||
printf '%s\n' '{"token":""}' | run_helper write >/dev/null || fail 'token clear failed'
|
||||
cleared="$(run_helper status)"
|
||||
jq -e '.configured == false and .tokenConfigured == false
|
||||
and .url == "https://new.example.test"
|
||||
and .entities == ["light.office", "light.hall"]' \
|
||||
<<<"$cleared" >/dev/null || fail "cleared state is wrong: $cleared"
|
||||
|
||||
before_hash="$(sha256sum "$env_file" | cut -d' ' -f1)"
|
||||
printf '%s\n' '{"url":"file:///etc/passwd"}' | run_helper write >/dev/null 2>&1 \
|
||||
&& fail 'invalid URL was accepted'
|
||||
after_hash="$(sha256sum "$env_file" | cut -d' ' -f1)"
|
||||
[[ "$before_hash" == "$after_hash" ]] || fail 'rejected input still modified the private env file'
|
||||
|
||||
# Exercise the actual QML Process.write() boundary with a pre-existing token.
|
||||
# The IPC carries only non-secret fields; the helper must preserve the token.
|
||||
config_path="$work/quickshell"
|
||||
harness="$config_path/home-assistant-config-harness.qml"
|
||||
cp -a "$repo_dir/config/dot/quickshell" "$config_path"
|
||||
cp "$harness_fixture" "$harness"
|
||||
printf '%s\n' \
|
||||
"export PANAMA_HOME_ASSISTANT_URL='https://qml-old.example.test'" \
|
||||
"export PANAMA_HOME_ASSISTANT_TOKEN='qml-private-token'" \
|
||||
"export PANAMA_HOME_ASSISTANT_ENTITIES='light.old'" >"$env_file"
|
||||
chmod 0600 "$env_file"
|
||||
|
||||
qs_for_test() {
|
||||
PANAMA_HOME_ASSISTANT_ENV_FILE="$env_file" \
|
||||
XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" \
|
||||
qs -p "$harness" "$@"
|
||||
}
|
||||
|
||||
stop_harness() {
|
||||
qs_for_test kill >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
qs_for_test --daemonize >/dev/null
|
||||
for _ in $(seq 1 60); do
|
||||
qs_for_test ipc show 2>/dev/null | rg -q '^target home-assistant-config-test$' && break
|
||||
sleep 0.1
|
||||
done
|
||||
qs_for_test ipc show 2>/dev/null | rg -q '^target home-assistant-config-test$' \
|
||||
|| fail 'credential QML harness did not start'
|
||||
|
||||
for _ in $(seq 1 60); do
|
||||
qml_status="$(qs_for_test ipc call home-assistant-config-test status)"
|
||||
jq -e '.busy == false and .url == "https://qml-old.example.test"' <<<"$qml_status" >/dev/null && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.configured == true and .tokenConfigured == true and .pendingPayloadEmpty == true' \
|
||||
<<<"$qml_status" >/dev/null || fail "QML service did not load redacted state: $qml_status"
|
||||
|
||||
qs_for_test ipc call home-assistant-config-test save \
|
||||
https://qml-new.example.test 'light.office,light.hall' >/dev/null \
|
||||
|| fail 'QML service refused a non-secret update'
|
||||
for _ in $(seq 1 60); do
|
||||
qml_status="$(qs_for_test ipc call home-assistant-config-test status)"
|
||||
jq -e '.busy == false and .url == "https://qml-new.example.test"
|
||||
and .entities == ["light.office", "light.hall"]' <<<"$qml_status" >/dev/null && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.configured == true and .tokenConfigured == true
|
||||
and .pendingPayloadEmpty == true and .lastError == ""' \
|
||||
<<<"$qml_status" >/dev/null || fail "QML stdin save did not settle safely: $qml_status"
|
||||
rg -Fq 'qml-private-token' "$env_file" || fail 'QML non-secret save erased the stored token'
|
||||
|
||||
qs_for_test ipc call home-assistant-config-test clearToken >/dev/null \
|
||||
|| fail 'QML service refused token clear'
|
||||
for _ in $(seq 1 60); do
|
||||
qml_status="$(qs_for_test ipc call home-assistant-config-test status)"
|
||||
jq -e '.busy == false and .tokenConfigured == false' <<<"$qml_status" >/dev/null && break
|
||||
sleep 0.1
|
||||
done
|
||||
jq -e '.configured == false and .tokenConfigured == false
|
||||
and .pendingPayloadEmpty == true and .lastError == ""' \
|
||||
<<<"$qml_status" >/dev/null || fail "QML token clear did not settle safely: $qml_status"
|
||||
if rg -Fq 'qml-private-token' "$env_file"; then
|
||||
fail 'QML token clear left the old token in the private env file'
|
||||
fi
|
||||
stop_harness
|
||||
|
||||
trap - EXIT
|
||||
cleanup
|
||||
printf 'Home Assistant config contract: PASS\n'
|
||||
Reference in New Issue
Block a user