diff --git a/README.md b/README.md index af73cee..ccc2d1b 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,10 @@ skills/ Agent skills for operating this desktop, linked into ~/.claude/skills setup/ apps/ Applications built from source, one file each - lib/ Shared by more than one stage; the extras catalog reader + keys/ Pinned signing keys, for publishers that ship no fetchable + one; setup/keys/README.md records where each came from + lib/ Shared by more than one stage; the extras catalog reader, + the machine role, the verified ChatGPT repository packages/ One package per line; extras/ holds the optional categories scripts/ Run in order by ./install tests/ Contracts. See below @@ -198,7 +201,7 @@ docs/ Settings reference, and the design specs behind the work ## Tests -186 of them, under `tests/`. `tests/contracts.manifest` classifies every +187 of them, under `tests/`. `tests/contracts.manifest` classifies every contract by the capabilities it needs. Run the hermetic set, or grant a specific external capability when automation needs it: diff --git a/bin/panama b/bin/panama index 0779d9f..5fb9e0c 100755 --- a/bin/panama +++ b/bin/panama @@ -134,7 +134,6 @@ ${BOLD}Examples:${RESET} $PROGRAM upgrade $PROGRAM apps $PROGRAM app - $PROGRAM app chatgpt-desktop EOF } diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index 085ea3f..afb9ddd 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -1805,7 +1805,7 @@ Singleton { "org.mozilla.thunderbird_esr", "com.slack.Slack", "app.bluebubbles.BlueBubbles", "rustdesk", "io.podman_desktop.PodmanDesktop", "com.anthropic.Claude", - "codex-desktop", "md.obsidian.Obsidian", + "chatgpt", "md.obsidian.Obsidian", "com.obsproject.Studio", "steam" ] }, diff --git a/migrations/1787804505.sh b/migrations/1787804505.sh new file mode 100755 index 0000000..7711f0e --- /dev/null +++ b/migrations/1787804505.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash + +# replace the community ChatGPT Desktop build with the official OpenAI package +# +# `panama app chatgpt-desktop` used to build a community wrapper (codex-desktop) +# from the upstream macOS disk image, complete with a local rebuild daemon. +# OpenAI ships an official Linux RPM now, and the installer takes that instead; +# this repairs machines still carrying the community build. The official +# package goes on before the community one comes off, so a failure part-way +# leaves the machine with an app, never without one. +# +# Rules, because the runner cannot enforce them: +# +# * Safe to run twice. The marker records success, not intent. +# * Tolerant of the repair already being correct -- the user may have fixed +# it by hand, or a later ./install may have put it back. +# * Root work goes through `panama-sudo --reason "..."`, never bare sudo, +# so the password prompt names the repair. +# * Exit non-zero to be retried at the next login. Exit zero only when the +# machine is genuinely in the state this describes. + +set -euo pipefail + +PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}" + +# Machines that never had the community build are already correct. The official +# app is the installer's job, not this one's. +rpm -q codex-desktop >/dev/null 2>&1 || exit 0 + +# The same verified repository the installer establishes: the pinned signing +# key, then a repository that names it, so dnf checks OpenAI's signature before +# root installs anything. See setup/lib/chatgpt-package. +# shellcheck source=../setup/lib/chatgpt-package +source "$PANAMA_PATH/setup/lib/chatgpt-package" + +sudo_cmd=(sudo) +if [[ -t 0 && -x "$PANAMA_PATH/bin/panama-sudo" ]]; then + sudo_cmd=( + "$PANAMA_PATH/bin/panama-sudo" --reason + "Replacing the community-built ChatGPT Desktop (codex-desktop) with the official OpenAI package" + -- + ) +fi + +# The official package first, so the machine is never left without one. +if ! rpm -q chatgpt >/dev/null 2>&1; then + chatgpt_install_repository "${sudo_cmd[@]}" + "${sudo_cmd[@]}" dnf install -y chatgpt +fi + +# The community package's updater is a user unit; stop it before dnf removes +# the unit file out from under it. Removal also takes the app in /opt, both +# binaries, and the polkit policy the local rebuilds needed. +systemctl --user disable --now codex-update-manager.service 2>/dev/null || true +"${sudo_cmd[@]}" dnf remove -y codex-desktop +systemctl --user daemon-reload 2>/dev/null || true + +# The rebuild state the updater kept; the official package needs none of it. +rm -rf "${XDG_CACHE_HOME:-$HOME/.cache}/codex-update-manager" \ + "${XDG_CACHE_HOME:-$HOME/.cache}/codex-runtimes" \ + "${XDG_CONFIG_HOME:-$HOME/.config}/codex-update-manager" \ + "${XDG_STATE_HOME:-$HOME/.local/state}/codex-update-manager" + +echo "Replaced the community codex-desktop build with the official chatgpt package." diff --git a/setup/apps/chatgpt-desktop b/setup/apps/chatgpt-desktop deleted file mode 100644 index c640613..0000000 --- a/setup/apps/chatgpt-desktop +++ /dev/null @@ -1,19 +0,0 @@ -# ChatGPT Desktop. -# -# OpenAI ships macOS and Windows only. This is a community wrapper that converts -# the upstream macOS disk image into a Linux Electron app and packages it as an -# RPM, so the installed result is again something dnf owns. -# -# Same exception, same reason: there is no packaged form to prefer. Nothing is -# pinned; `bootstrap-native` fetches the current upstream image each time and -# fails loudly when it cannot. - -description="ChatGPT Desktop, built into a Fedora RPM" -repo="https://github.com/ilysenko/codex-desktop-linux.git" - -# bootstrap-native installs build dependencies, builds, packages, and installs -# the newest artifact -- so unlike the Claude build there is no separate install -# step to do here. -build() { - make bootstrap-native -} diff --git a/setup/keys/README.md b/setup/keys/README.md new file mode 100644 index 0000000..336abbf --- /dev/null +++ b/setup/keys/README.md @@ -0,0 +1,38 @@ +# Pinned signing keys + +A key lands here when a publisher signs what Panama installs but does not +publish the key, or its fingerprint, anywhere an install could fetch and check +them first. Pinning the key is what lets `dnf` verify a download before root +ever sees it. + +Nothing here is a secret. These are public keys, and the reason to track them +is that a *changed* one should be a merge request somebody reads, not a silent +change of who is trusted. + +## `RPM-GPG-KEY-chatgpt` + +| | | +| --- | --- | +| Fingerprint | `3BFA0E4AE8B8CC16A2D9BA684A3B4A566C4660E4` | +| User ID | `Codex Linux Repository` | +| Signs | the `chatgpt` package and the repository metadata at `https://persistent.oaistatic.com/codex-app-prod/linux/rpm/$basearch` | +| Used by | `setup/lib/chatgpt-package` | + +Captured on 2026-08-27 from a machine where the official package had been +installed, at `/etc/pki/rpm-gpg/RPM-GPG-KEY-chatgpt`, where the package's own +root scriptlet writes it. It is the key that signed both the installed +`chatgpt` package and the live `repodata/repomd.xml.asc`. + +Be honest about what that is worth: OpenAI's documented instructions +() are to download an RPM and +install it, and they publish no key URL and no fingerprint to compare against. +So this is trust established on first use and then held, not trust verified +against the publisher. Held is the part that matters -- from here every machine +checks the same fingerprint, and a swapped download fails instead of installing. + +To re-derive the fingerprint from the file: + +```bash +gpg --show-keys --with-colons setup/keys/RPM-GPG-KEY-chatgpt \ + | awk -F: '$1 == "fpr" { print $10; exit }' +``` diff --git a/setup/keys/RPM-GPG-KEY-chatgpt b/setup/keys/RPM-GPG-KEY-chatgpt new file mode 100644 index 0000000..cc1c7cc --- /dev/null +++ b/setup/keys/RPM-GPG-KEY-chatgpt @@ -0,0 +1,28 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGpypFUBEACi1Vvzq9pIpA6lj7chbqELuxJtVuzUzxrasa6ZU0yF4yhq7jf8 +3YkJRHwbezBKeQyzJ5lkX0EhXS8aXxUhMAm3PFpAlwcInfKzmV7atJwvaxIw6Rmd +GYe9fBWKjTN/SmPIjtyxrTznZY97+TfD1AeGZpLaJ8fsnhrC+HkiN2TACiTocgpe +hFiP0OWK7mWZeTWnY2scpIYXP1Ro7nQv4KacmY4JacTQ7m/HM0Qej/3olhuEv2Cw +lMVWw57/oHhmTllfLDQOogFQyIVqaaR98y/Eu6cAabSfcsqAAZ2A8vfHYD27z28J +vLO2PZEJd5ThlnX4Zqv0eIpZdBj//8Sl/MSqTshFZ1NDsRoqwdqw284X5MpnOJ4k +4Sc2Se8tJxt/nCeibH3dJ504Fb1X/mnOqhCAQ6pVJz4RB5HRlFPSkxVPyag1v1m/ +7T4vie+OR4eqFQNz6mudrOoMmeVIfyL5fbe4cOr4fk/FyvEE2xMgkFatPqXn7vM9 +og+zremPCfwRAFpBPyX74VowFY7llcdaj/w8K5T8PzM14Hb3E4ZKizMluKmTvTq9 +WE1/eSQJLLQqXD5VmtmdUaC/VyE/1ZlIxcA1LWqvEQ327UXREvX/nHsrkKrl956W +jzkiHFUTsD1NJ0dMfs+csOt8Furb5jZj+HsMmCm9jLdfz5b/4WKLPbvxIwARAQAB +tBZDb2RleCBMaW51eCBSZXBvc2l0b3J5iQJRBBMBCgA7FiEEO/oOSui4zBai2bpo +SjtKVmxGYOQFAmpypFUCGwMFCwkIBwICIgIGFQoJCAsCBBYCAwECHgcCF4AACgkQ +SjtKVmxGYORlCQ/9FyikZo8HQcJBP9E/oXVPds/fQnIFB2qJR2z3DrfYEonNt/ev +SAySkPPq4/mEOjaI0pFlDDGSaps+FTcJFgoVRTasBIF7JJivvjW9ap8iWEbhhVLe +IrFLbMLpUcTRntUx7R4fVMJ/1/cGn+NWZmNwS9ORorzSyCH0IAgCw1Xc3ZrjuMbF +VjdToMC1TiXXCEmlYpQakmQ3Ay1cH0FHC2BBNn1MNVkJdPhpZIZCdhaMPHfYFpyo +pg8wFvZ5iIcvlbMgyuy8CPJVRWUcYy2dOhEOGnYJnXRPkE3E1hf8YOHNzRlduH89 +6lT9qcEK2+fpLfrVGoc4zscLZ+Ey+Ko6iQRdVE1j67+wNR3hX8ukue574v1N/xxu +i575jumSE19lEj1sH4+P4gFHOtTbF0JhKKzLctbga0IAwTPKhnt3qzj1U5Yj/MZS +uEVjrLhdRauOuFBXUclgyVf2w/lE85UUOdlcollsYA6Huq7xDamqf8SslZQGre3E +I+lhpqJR1cOwDMUzzcl40uTyhrxXXd/bk4QSlhZbwHR25Pnt+ZMtWavlQWS0eDEV +8djuXAURCmx5WOqAFB/TJe1mn5EvyWg4VFzrY/NVNOpzgY5+Xp7J28z7f637r712 +Eu9j4imVcdPigwS+jf/0f81i2o9b82Y26TN8+EtDLCY841MJ1lrjDrX/dno= +=Y+3h +-----END PGP PUBLIC KEY BLOCK----- diff --git a/setup/lib/chatgpt-package b/setup/lib/chatgpt-package new file mode 100644 index 0000000..f608c79 --- /dev/null +++ b/setup/lib/chatgpt-package @@ -0,0 +1,97 @@ +# Installing OpenAI's ChatGPT Desktop without trusting the download. Sourced, +# not run. +# +# OpenAI signs both its packages and its repository metadata, with one key, and +# publishes neither that key nor its fingerprint anywhere a first install could +# fetch them. The documented instructions are "download this RPM and install +# it" -- and the RPM's own root scriptlet is what writes the repository file and +# drops the key into /etc/pki/rpm-gpg. Following them means handing an +# unverified download to root and letting it decide afterwards what to trust, +# which is the one thing this repository will not do with a network response. +# +# So the key is pinned here instead. setup/keys/ carries a copy and records +# where it came from; this verifies that copy's fingerprint, installs it, and +# writes the repository itself with gpgcheck on. dnf then checks the metadata +# signature and the package signature against that key before anything runs as +# root, and every later upgrade goes through the same repository and the same +# key. +# +# Two callers, which is why this is a library: install-packages, for a machine +# being built, and the migration that replaces the community codex-desktop +# build on machines that predate the official package. + +# The key that signs the packages and the repository metadata. Pinned, so a +# substituted key is a failure here rather than a silent change of publisher. +CHATGPT_KEY_FINGERPRINT="3BFA0E4AE8B8CC16A2D9BA684A3B4A566C4660E4" + +# `$basearch` stays literal: dnf expands it, and this is the same base URL the +# package's own scriptlet configures. +CHATGPT_REPO_BASEURL="https://persistent.oaistatic.com/codex-app-prod/linux/rpm/\$basearch" +CHATGPT_REPO_FILE="/etc/yum.repos.d/chatgpt.repo" +CHATGPT_KEY_FILE="/etc/pki/rpm-gpg/RPM-GPG-KEY-chatgpt" + +chatgpt_pinned_key() { + printf '%s/setup/keys/RPM-GPG-KEY-chatgpt' "${PANAMA_PATH:-$HOME/.local/share/Panama}" +} + +# The fingerprint of the pinned copy. Nonzero when it cannot be read at all, +# which the caller reports differently from a key that reads but is the wrong +# one. +chatgpt_pinned_fingerprint() { + local key + key="$(chatgpt_pinned_key)" + [[ -r "$key" ]] || return 1 + gpg --show-keys --with-colons "$key" 2>/dev/null \ + | awk -F: '$1 == "fpr" { print $10; exit }' +} + +# Fails without touching anything when the pinned key is missing, unreadable, +# or not the key this repository says it is. Everything below assumes it passed. +chatgpt_verify_pinned_key() { + local found + if ! command -v gpg >/dev/null 2>&1; then + printf 'gpg is missing, so the pinned ChatGPT signing key cannot be verified.\n' >&2 + return 1 + fi + if ! found="$(chatgpt_pinned_fingerprint)"; then + printf 'The pinned ChatGPT signing key is missing: %s\n' "$(chatgpt_pinned_key)" >&2 + return 1 + fi + if [[ "$found" != "$CHATGPT_KEY_FINGERPRINT" ]]; then + printf 'The pinned ChatGPT signing key is %s, not the expected %s.\n' \ + "${found:-unreadable}" "$CHATGPT_KEY_FINGERPRINT" >&2 + return 1 + fi +} + +# Installs the verified key and the repository that names it, so the install +# after this one is a signature check rather than an act of faith. +# +# Takes the command that gets root, because the two callers ask for it +# differently: plain `sudo` from the installer, which authenticated once at the +# top of the run, and `panama-sudo --reason ...` from a migration, whose prompt +# has to say which repair it is for. +chatgpt_install_repository() { + local -a sudo_cmd=("$@") + (( ${#sudo_cmd[@]} > 0 )) || sudo_cmd=(sudo) + + chatgpt_verify_pinned_key || return 1 + + "${sudo_cmd[@]}" install -D -m 0644 "$(chatgpt_pinned_key)" "$CHATGPT_KEY_FILE" || return 1 + "${sudo_cmd[@]}" rpmkeys --import "$CHATGPT_KEY_FILE" || return 1 + + # Written here rather than left to the package's scriptlet, because the + # point of it is to exist -- with gpgcheck on and this key named -- before + # the first install rather than after it. Same base URL and same key the + # scriptlet writes, so it finds nothing to change later. + printf '%s\n' \ + '[openai-chatgpt]' \ + 'name=ChatGPT' \ + "baseurl=$CHATGPT_REPO_BASEURL" \ + 'enabled=1' \ + 'type=rpm-md' \ + 'gpgcheck=1' \ + 'repo_gpgcheck=1' \ + "gpgkey=file://$CHATGPT_KEY_FILE" \ + | "${sudo_cmd[@]}" tee "$CHATGPT_REPO_FILE" >/dev/null || return 1 +} diff --git a/setup/scripts/install-packages b/setup/scripts/install-packages index 3eac3d3..43dde68 100755 --- a/setup/scripts/install-packages +++ b/setup/scripts/install-packages @@ -79,6 +79,11 @@ source "$PANAMA_PATH/setup/lib/extras-catalog" source "$PANAMA_PATH/setup/lib/machine-role" ROLE="$(panama_role)" +# Establishing the verified ChatGPT repository, shared with the migration that +# replaces the community build, so neither can install it a less careful way. +# shellcheck source=../lib/chatgpt-package +source "$PANAMA_PATH/setup/lib/chatgpt-package" + # One list, installed the way every list is installed: --skip-unavailable so a # single rotted name cannot cost the transaction, then report_missing so a # skipped name is a warning somebody reads. @@ -369,6 +374,28 @@ else || { log "Claude Desktop install failed; skipping"; softly_failed+=("Claude Desktop"); } fi +# ChatGPT Desktop: OpenAI ships an official Linux RPM now. Panama used to build +# a community wrapper from the macOS disk image -- it was `panama app +# chatgpt-desktop` -- because no packaged form existed; that build froze often +# and carried its own local rebuild daemon. The official package is strictly +# better: it comes from a repository, so it upgrades with every other package +# from then on. +# +# The repository and its signing key are established first, from the copy +# pinned in setup/keys/, so dnf verifies the metadata and the package before +# either reaches root. Upstream's own instructions do not allow that -- see +# setup/lib/chatgpt-package for why they are not followed here. +if rpm -q chatgpt >/dev/null 2>&1; then + log "ChatGPT Desktop already installed" +elif ! chatgpt_install_repository sudo; then + log "Could not establish the verified ChatGPT repository; skipping" + softly_failed+=("ChatGPT Desktop") +else + log "Installing ChatGPT Desktop..." + sudo dnf install -y chatgpt > /dev/null \ + || { log "ChatGPT Desktop install failed; skipping"; softly_failed+=("ChatGPT Desktop"); } +fi + # RustDesk: remote desktop. The flatpak cannot register the root-owned system # service that unattended access needs -- see panama-doctor's rustdesk check -- # so this takes the RPM. The download URL is resolved from the latest release diff --git a/tests/contracts.manifest b/tests/contracts.manifest index 04468fc..ba23dc6 100644 --- a/tests/contracts.manifest +++ b/tests/contracts.manifest @@ -247,6 +247,7 @@ hermetic tests/server/containers-shape-contract hermetic tests/server/panama-server-contract hermetic tests/setup/apps-contract hermetic tests/setup/boot-contract +hermetic tests/setup/chatgpt-package-contract hermetic tests/setup/contract-manifest-contract hermetic tests/setup/crash-watch-contract hermetic tests/setup/desktop-first-contract diff --git a/tests/setup/chatgpt-package-contract b/tests/setup/chatgpt-package-contract new file mode 100755 index 0000000..8ad9dbd --- /dev/null +++ b/tests/setup/chatgpt-package-contract @@ -0,0 +1,195 @@ +#!/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'