#!/usr/bin/env bash # The one download that gets to run as root, and how it earns that. # # OpenAI publishes no signing key and no fingerprint that a first install could # fetch and compare against: the documented instructions are to download an RPM # and install it, and that RPM's own root scriptlet is what decides afterwards # which repository and which key the machine will trust. Panama pins the key # instead -- setup/keys/ carries it, setup/lib/chatgpt-package verifies the copy # and writes the repository -- so dnf checks a signature before root sees a byte # of it. # # What must hold: # # 1. The pinned key is the key the library says it is. Everything else here # is worthless if this drifts, and a changed key must be a failing test # somebody reads rather than a quiet change of publisher. # 2. A pinned key that is missing, unreadable, or simply not that key stops # the install and leaves the machine untouched. Failing closed is the # whole point; falling back to installing anyway would be worse than # never having checked. # 3. What it writes actually enforces the check: gpgcheck and repo_gpgcheck # on, and the gpgkey pointing at the key it just installed. # 4. Both callers go through it, and neither hands root a downloaded RPM. # The installer and the codex-desktop migration install `chatgpt` by name # from that repository, which is what makes the signature mandatory. # # Hermetic: the key file is read locally, root is a stub that records what it # was asked to do, and the destinations are redirected into a temporary # directory. Nothing here contacts OpenAI or touches /etc. set -uo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" library="$repo_dir/setup/lib/chatgpt-package" installer="$repo_dir/setup/scripts/install-packages" migration="$repo_dir/migrations/1787804505.sh" pinned_key="$repo_dir/setup/keys/RPM-GPG-KEY-chatgpt" findings=() note() { findings+=("$1"); } [[ -r "$library" ]] || { printf 'chatgpt package contract: %s is missing\n' "$library" >&2 exit 1 } command -v gpg >/dev/null 2>&1 || { printf 'chatgpt package contract: gpg is required to read the pinned key\n' >&2 exit 1 } work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT # Root, as a recording stub. It logs the command and then runs it for real, # which is safe because every destination below is redirected into $work. stub="$work/bin" mkdir -p "$stub" cat >"$stub/sudo" <<'STUB' #!/usr/bin/env bash printf '%s\n' "$*" >>"$SUDO_RECORD" exec "$@" STUB cat >"$stub/rpmkeys" <<'STUB' #!/usr/bin/env bash printf '%s\n' "$*" >>"$RPMKEYS_RECORD" STUB chmod +x "$stub/sudo" "$stub/rpmkeys" export PATH="$stub:$PATH" # ── 1. The pinned key is the pinned key ───────────────────────────────────── if [[ ! -r "$pinned_key" ]]; then note 'setup/keys/RPM-GPG-KEY-chatgpt is missing, so nothing can be verified' else declared="$(grep -oP '(?<=^CHATGPT_KEY_FINGERPRINT=")[0-9A-F]+' "$library" | head -1)" actual="$(gpg --show-keys --with-colons "$pinned_key" 2>/dev/null \ | awk -F: '$1 == "fpr" { print $10; exit }')" [[ -n "$declared" ]] \ || note 'the library pins no fingerprint, so any key file would be accepted' [[ -n "$actual" ]] \ || note 'the pinned key file does not parse as a public key' [[ "$declared" == "$actual" ]] \ || note "the pinned key is $actual but the library expects $declared" grep -q 'RPM-GPG-KEY-chatgpt' "$repo_dir/setup/keys/README.md" 2>/dev/null \ || note 'setup/keys/README.md does not record where the pinned key came from' grep -q "$actual" "$repo_dir/setup/keys/README.md" 2>/dev/null \ || note 'setup/keys/README.md records a fingerprint other than the key it ships' fi # One attempt, against redirected destinations and a recording root. Every # variable the library exposes is set here rather than in the caller's shell, # so a case cannot leak into the next one. attempt() { local dir="$1" panama_path="$2" fingerprint="${3:-}" mkdir -p "$dir" ( export SUDO_RECORD="$dir/sudo.log" RPMKEYS_RECORD="$dir/rpmkeys.log" : >"$SUDO_RECORD" : >"$RPMKEYS_RECORD" PANAMA_PATH="$panama_path" # shellcheck source=/dev/null source "$library" CHATGPT_KEY_FILE="$dir/pki/RPM-GPG-KEY-chatgpt" CHATGPT_REPO_FILE="$dir/repos/chatgpt.repo" mkdir -p "$dir/repos" [[ -z "$fingerprint" ]] || CHATGPT_KEY_FINGERPRINT="$fingerprint" chatgpt_install_repository sudo ) >"$dir/out" 2>&1 } # ── 2. It fails closed ────────────────────────────────────────────────────── # A checkout with no pinned key at all. empty="$work/no-key" mkdir -p "$empty/checkout/setup/keys" attempt "$empty" "$empty/checkout" \ && note 'a missing pinned key still established the repository' [[ ! -e "$empty/repos/chatgpt.repo" ]] \ || note 'a missing pinned key still wrote a repository file' grep -qi 'missing' "$empty/out" \ || note 'a missing pinned key does not say so' # A key file that is not a key. garbage="$work/garbage-key" mkdir -p "$garbage/checkout/setup/keys" printf 'not a key\n' >"$garbage/checkout/setup/keys/RPM-GPG-KEY-chatgpt" attempt "$garbage" "$garbage/checkout" \ && note 'an unreadable pinned key still established the repository' [[ ! -e "$garbage/repos/chatgpt.repo" ]] \ || note 'an unreadable pinned key still wrote a repository file' # The real key, against a fingerprint that is not its own -- the shape a # substituted publisher would take. wrong="$work/wrong-fingerprint" attempt "$wrong" "$repo_dir" '0000000000000000000000000000000000000000' \ && note 'a key that does not match the pinned fingerprint was accepted' [[ ! -e "$wrong/repos/chatgpt.repo" ]] \ || note 'a fingerprint mismatch still wrote a repository file' [[ ! -s "$wrong/rpmkeys.log" ]] \ || note 'a fingerprint mismatch still imported the key into the rpm keyring' # ── 3. What it writes enforces the check ──────────────────────────────────── good="$work/verified" if ! attempt "$good" "$repo_dir"; then note "the pinned key was rejected: $(cat "$good/out")" else repo_file="$good/repos/chatgpt.repo" key_file="$good/pki/RPM-GPG-KEY-chatgpt" cmp -s "$key_file" "$pinned_key" \ || note 'the installed key is not the pinned key' grep -q 'import' "$good/rpmkeys.log" \ || note 'the verified key was never imported, so dnf has nothing to check against' grep -qx 'gpgcheck=1' "$repo_file" \ || note 'the repository does not set gpgcheck=1, so package signatures go unchecked' grep -qx 'repo_gpgcheck=1' "$repo_file" \ || note 'the repository does not set repo_gpgcheck=1, so the metadata goes unchecked' grep -qx "gpgkey=file://$key_file" "$repo_file" \ || note 'the repository does not point gpgkey at the key that was just installed' grep -q 'baseurl=https://' "$repo_file" \ || note 'the repository has no https base URL' fi # ── 4. Both callers go through it ─────────────────────────────────────────── for caller in "$installer" "$migration"; do name="${caller#"$repo_dir"/}" [[ -r "$caller" ]] || { note "$name is missing"; continue; } grep -q 'setup/lib/chatgpt-package' "$caller" \ || note "$name does not source the verified install library" grep -q 'chatgpt_install_repository' "$caller" \ || note "$name does not establish the verified repository before installing" grep -qE 'dnf install -y chatgpt\b' "$caller" \ || note "$name does not install chatgpt by name from that repository" # The shape this contract exists to keep out: fetch an RPM, hand it to # root, and let its scriptlet decide what the machine trusts afterwards. grep -qE 'curl.*chatgpt.*\.rpm' "$caller" \ && note "$name downloads a ChatGPT RPM instead of installing it from the verified repository" grep -qE 'dnf install[^|]*\$\{?chatgpt_rpm' "$caller" \ && note "$name installs a downloaded ChatGPT RPM as root" done if (( ${#findings[@]} > 0 )); then printf 'chatgpt package contract: %d finding(s)\n' "${#findings[@]}" >&2 printf ' - %s\n' "${findings[@]}" >&2 exit 1 fi printf 'chatgpt package contract: PASS\n'