From 6f17574e4043c16df24f20a4d79086c84d871fb9 Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Fri, 7 Aug 2026 22:50:51 -0700 Subject: [PATCH] Support secure dialog input on the Parallels macOS 26/27 lanes Granting Accessibility or Input Monitoring on macOS 27 raises an authenticated password prompt, and secure-dialog-input refused every lane but Tart macOS 15, so the denied-to-allowed transition could not be automated at all on the lanes KeyPath and Typover actually test. The Tart path cannot simply be reused: it requires peekaboo and mcporter under /opt/homebrew inside the guest, and the Parallels desktop base has no Homebrew. Deliver the credential instead with the key-event transport secure_console_submit already proves out, which needs nothing installed in the guest. The plaintext is still read only from an owner-only temp file and never enters argv, logs, the guest pasteboard, or an artifact, and the accepted alphabet stays as narrow. Verify the sheet closed before reporting success, matching the Tart lane, using the peekaboo the base now installs at a stable path. Delivery is not success; without this check a credential that never reached the field reports a pass. Tests cover the happy path -- key codes matching the secret exactly, Return sent, no plaintext in logs or arguments -- and the stuck-sheet case. Defeating the postcondition makes the suite fail, so the assertion bites. Co-Authored-By: Claude Opus 5 --- Scripts/lab/remote.sh | 100 ++++++++++++++++++++++++- Scripts/lab/tests/keypath-lab-tests.sh | 39 ++++++++++ 2 files changed, 138 insertions(+), 1 deletion(-) diff --git a/Scripts/lab/remote.sh b/Scripts/lab/remote.sh index 49377d82d..5aab86c50 100755 --- a/Scripts/lab/remote.sh +++ b/Scripts/lab/remote.sh @@ -882,12 +882,110 @@ run_command() { return "$exit_code" } +# Run peekaboo inside a Parallels guest as the console user. The base installs +# it at a stable path because the guest has no Homebrew, and AX queries only see +# the session when they run as the console user rather than as root. +parallels_guest_peekaboo() { + local resource=$1 parallels_cli=$2 + shift 2 + local guest_peekaboo=${KEYPATH_LAB_GUEST_PEEKABOO:-/usr/local/bin/peekaboo} + local quoted + printf -v quoted '%q ' "$@" + # prlctl exec mangles the arguments of the first command in the string, so + # lead with a no-op before the command that matters. + "$parallels_cli" exec "$resource" /bin/zsh -lc \ + "true; sudo -u keypathqa $guest_peekaboo $quoted" +} + +# macOS 26 and 27 run on Parallels, whose guests carry neither the peekaboo nor +# the mcporter paths under /opt/homebrew that the Tart lane depends on. Deliver +# the credential with the same Parallels key-event transport secure_console_submit +# uses -- it requires nothing inside the guest -- and verify the sheet closed +# using the peekaboo the base installs at a stable path. +secure_dialog_input_parallels() { + local lease=$1 app=$2 field_label=$3 submit_button=$4 already_focused=$5 + local manifest macos resource parallels_cli secret_file attempt closed + manifest=$(owned_manifest "$lease") + macos=$(field "$manifest" macos) + [[ "$(field "$manifest" provider)" == "parallels" ]] || die "secure dialog input on macOS $macos requires a Parallels lease" + [[ "$(field "$manifest" desktop_enabled)" == "true" ]] || die "secure dialog input requires a desktop-enabled lease" + [[ -n "$submit_button" ]] || die "secure dialog input requires a submit button so the sheet closing can be verified" + resource=$(field "$manifest" provider_resource) + [[ "$resource" =~ '^[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}$' ]] || die "invalid Parallels resource id" + parallels_cli=${KEYPATH_LAB_PRLCTL:-"/Applications/Parallels Desktop.app/Contents/MacOS/prlctl"} + [[ -x "$parallels_cli" ]] || die "Parallels CLI is unavailable" + + if [[ "${KEYPATH_LAB_TESTING:-0}" == "1" ]]; then + secret_file="${KEYPATH_LAB_TEST_SECRET_FILE:?test secret file is required}" + else + secret_file=$(mktemp "$STATE_ROOT/.secure-input.XXXXXXXX") + chmod 600 "$secret_file" + typeset -g KEYPATH_LAB_SECURE_TEMP="$secret_file" + trap '[[ -z ${KEYPATH_LAB_SECURE_TEMP:-} ]] || rm -f "$KEYPATH_LAB_SECURE_TEMP"' EXIT + /opt/homebrew/bin/sops -d "$HOME/dotfiles/secrets.env" | awk -F= '$1 == "KEYPATH_LAB_GUEST_PASSWORD" {sub(/^[^=]*=/, ""); printf "%s", $0; found=1} END {if (!found) exit 1}' > "$secret_file" || die "KEYPATH_LAB_GUEST_PASSWORD is unavailable" + fi + [[ -s "$secret_file" ]] || die "secure input secret is empty" + + # An authentication sheet focuses its password field on presentation, so the + # common case needs no click. Focus explicitly only when asked to. + if [[ "$already_focused" == "0" ]]; then + parallels_guest_peekaboo "$resource" "$parallels_cli" \ + click "$field_label" --app "$app" --foreground --json >/dev/null 2>&1 \ + || die "secure dialog input failed while focusing the field" + fi + + # Identical transport to secure_console_submit: the plaintext is read only + # from the owner-only temp file and never enters argv, logs, the guest + # pasteboard, or an artifact. The alphabet stays deliberately narrow; + # widening it requires an explicit key-map review. + python3 -c 'import json,sys +codes={"a":38,"b":56,"c":54,"d":40,"e":26,"f":41,"g":42,"h":43,"i":31,"j":44,"k":45,"l":46,"m":58,"n":57,"o":32,"p":33,"q":24,"r":27,"s":39,"t":28,"u":30,"v":55,"w":25,"x":53,"y":29,"z":52,"1":10,"2":11,"3":12,"4":13,"5":14,"6":15,"7":16,"8":17,"9":18,"0":19,"-":20} +value=open(sys.argv[1],"r",encoding="utf-8").read() +if not value or any(ch not in codes for ch in value): raise SystemExit(64) +for ch in value: print(json.dumps([{"key":codes[ch]}],separators=(",",":")))' "$secret_file" 3<&- | \ + while IFS= read -r key_event <&3; do + printf '%s\n' "$key_event" | "$parallels_cli" send-key-event "$resource" --json >/dev/null || exit 1 + sleep "${KEYPATH_LAB_SECURE_CONSOLE_KEY_DELAY_SECONDS:-0.2}" + done 3<&0 || die "secure dialog input failed while delivering the credential" + sleep "${KEYPATH_LAB_SECURE_CONSOLE_SETTLE_SECONDS:-0.25}" + "$parallels_cli" send-key-event "$resource" --key 36 >/dev/null || die "secure dialog input failed while submitting the dialog" + + if [[ "${KEYPATH_LAB_TESTING:-0}" != "1" ]]; then + rm -f "$secret_file" + KEYPATH_LAB_SECURE_TEMP= + trap - EXIT + fi + + # Delivery is not success. Require the submit control to disappear before + # reporting a pass, the same postcondition the Tart lane enforces. + closed=0 + for attempt in {1..150}; do + if ! parallels_guest_peekaboo "$resource" "$parallels_cli" \ + inspect-ui --app "$app" --json 2>/dev/null | grep -Fq "$submit_button"; then + closed=1 + break + fi + sleep 0.1 + done + if (( closed != 1 )); then + record_command "$lease" "failed:79" secure-dialog-input --app "$app" --field "$field_label" --submit "$submit_button" + die "secure dialog input was submitted but the authentication sheet did not close" + fi + record_command "$lease" passed secure-dialog-input --app "$app" --field "$field_label" --submit "$submit_button" + print "secure_dialog_input\tpassed" + print "credential_transport\tparallels-key-events" +} + secure_dialog_input() { local lease=$1 app=$2 field_label=$3 submit_button=$4 already_focused=$5 local manifest macos resource key ip secret_file guest_command exit_code manifest=$(owned_manifest "$lease") macos=$(field "$manifest" macos) - [[ "$macos" == "15" ]] || die "secure dialog input currently supports only the Tart macOS 15 lane" + if [[ "$macos" == "26" || "$macos" == "27" ]]; then + secure_dialog_input_parallels "$@" + return + fi + [[ "$macos" == "15" ]] || die "secure dialog input supports the Tart macOS 15 lane and the Parallels macOS 26/27 lanes" [[ "$(field "$manifest" desktop_enabled)" == "true" ]] || die "secure dialog input requires a desktop-enabled lease" resource=$(field "$manifest" provider_resource) [[ "$resource" =~ '^[A-Za-z0-9._-]+$' && "$resource" != "unknown" ]] || die "invalid Tart resource id" diff --git a/Scripts/lab/tests/keypath-lab-tests.sh b/Scripts/lab/tests/keypath-lab-tests.sh index 5a63501f7..32d2f8dc9 100755 --- a/Scripts/lab/tests/keypath-lab-tests.sh +++ b/Scripts/lab/tests/keypath-lab-tests.sh @@ -169,6 +169,14 @@ fi if [[ \$1 == send-key-event && " \$* " == *" --json "* ]]; then cat >> "$TMP/secure-console-key-events.jsonl" fi +if [[ \$1 == exec && " \$* " == *" inspect-ui "* ]]; then + # Stands in for the guest UI query behind the sheet-closed postcondition. + # Echo the submit control only when a test asks for a sheet that stays up. + if [[ \${KEYPATH_LAB_TEST_DIALOG_STAYS_OPEN:-0} == 1 ]]; then + echo 'Modify Settings' + fi + exit 0 +fi if [[ \$1 == exec && " \$* " == *" /usr/sbin/sysadminctl -autologin status "* ]]; then echo 'Automatic login is ON.' fi @@ -675,6 +683,37 @@ set -e [[ $console_login_bad_credential_status -ne 0 ]] assert_contains "$console_login_bad_credential" 'KEYPATH_LAB_GUEST_PASSWORD does not authenticate the keypathqa guest account' grep -q $'console_login_status\tcredential-mismatch' "$ROOT/KeyPathInstallerLab/leases/cbx_desktop27/manifest.tsv" + +# secure-dialog-input on the Parallels macOS 27 lane. The guest has no Homebrew, +# so this path uses the same key-event transport as secure-console-submit rather +# than the Tart lane's peekaboo/mcporter chain. +: > "$TMP/secure-console-key-events.jsonl" +secure_dialog_parallels=$(KEYPATH_LAB_SECURE_CONSOLE_KEY_DELAY_SECONDS=0 KEYPATH_LAB_SECURE_CONSOLE_SETTLE_SECONDS=0 \ + run_remote secure-dialog-input cbx_desktop27 'System Settings' Password 'Modify Settings' 1) +assert_contains "$secure_dialog_parallels" $'secure_dialog_input\tpassed' +assert_contains "$secure_dialog_parallels" $'credential_transport\tparallels-key-events' +# The credential must arrive as key codes, never as text. +python3 -c 'import json,sys +codes={"a":38,"b":56,"c":54,"d":40,"e":26,"f":41,"g":42,"h":43,"i":31,"j":44,"k":45,"l":46,"m":58,"n":57,"o":32,"p":33,"q":24,"r":27,"s":39,"t":28,"u":30,"v":55,"w":25,"x":53,"y":29,"z":52,"1":10,"2":11,"3":12,"4":13,"5":14,"6":15,"7":16,"8":17,"9":18,"0":19,"-":20} +events=[json.loads(line) for line in open(sys.argv[1]) if line.strip()] +expected=[[{"key":codes[ch]}] for ch in open(sys.argv[2]).read()] +assert events == expected' "$TMP/secure-console-key-events.jsonl" "$TMP/secure-input" +grep -q 'prlctl send-key-event 11111111-1111-1111-1111-111111111111 --key 36' "$CALLS" +if grep -R -F 'fixture-password-that-must-not-leak' "$ROOT/KeyPathInstallerLab" "$CALLS"; then + echo "parallels secure dialog input leaked its secret into logs or arguments" >&2 + exit 1 +fi + +# Delivery is not success: if the submit control is still present the command +# must fail rather than report a pass. +set +e +secure_dialog_stuck=$(KEYPATH_LAB_TEST_DIALOG_STAYS_OPEN=1 KEYPATH_LAB_SECURE_CONSOLE_KEY_DELAY_SECONDS=0 KEYPATH_LAB_SECURE_CONSOLE_SETTLE_SECONDS=0 \ + run_remote secure-dialog-input cbx_desktop27 'System Settings' Password 'Modify Settings' 1 2>&1) +secure_dialog_stuck_status=$? +set -e +[[ $secure_dialog_stuck_status -ne 0 ]] +assert_contains "$secure_dialog_stuck" 'authentication sheet did not close' + run_remote destroy cbx_desktop27 >/dev/null desktop26_create=$(run_remote create 26 unmanaged-ui "$archive_key" "$commit" "$checksum" KeyPath.zip 2h 1)