Install ChatGPT Desktop from a repository this checkout can verify
OpenAI ships an official Linux RPM now, so the community wrapper goes away: `panama app chatgpt-desktop` built codex-desktop from the upstream macOS disk image and ran a local rebuild daemon to keep it current, and the official package comes from a repository that upgrades with everything else. The app file, the help example and the dock's pinned id all move over, and a migration replaces the build on machines that already have it -- official package on before the community one comes off, so a failure part-way still leaves an app. The install itself does not follow upstream's instructions. Those are "download this RPM and install it", and the RPM's own root scriptlet is what writes the repository file and drops the signing key into /etc/pki/rpm-gpg -- so root runs an unverified download and then learns from it what to trust. That is the shape the repository audit forbids: no network response is executed as root without a verified digest or signature first. OpenAI publishes no key and no fingerprint anywhere an install could fetch and check them, so the key is pinned here instead. setup/keys/ carries it and says where it came from, including the honest part -- this is trust established on first use and then held, not trust verified against the publisher. setup/lib/ chatgpt-package verifies that copy's fingerprint, installs it, and writes the repository with gpgcheck and repo_gpgcheck on before anything is installed, so dnf checks the metadata signature and the package signature itself. It is byte for byte the repository the scriptlet would have written, so nothing churns afterwards, and every later upgrade goes through the same key. Both callers use it; a verification failure skips ChatGPT rather than installing it anyway. The contract proves the pinned key is the key the library names, that a missing, unreadable or mismatched key writes nothing at all, that what is written actually turns the checks on, and that neither caller hands root a downloaded RPM. Claude-Session: https://claude.ai/code/session_017zzbtfnMLoYrB8WesqANFY
This commit is contained in:
Executable
+195
@@ -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'
|
||||
Reference in New Issue
Block a user