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:
@@ -189,7 +189,10 @@ skills/ Agent skills for operating this desktop, linked into
|
|||||||
~/.claude/skills
|
~/.claude/skills
|
||||||
setup/
|
setup/
|
||||||
apps/ Applications built from source, one file each
|
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
|
packages/ One package per line; extras/ holds the optional categories
|
||||||
scripts/ Run in order by ./install
|
scripts/ Run in order by ./install
|
||||||
tests/ Contracts. See below
|
tests/ Contracts. See below
|
||||||
@@ -198,7 +201,7 @@ docs/ Settings reference, and the design specs behind the work
|
|||||||
|
|
||||||
## Tests
|
## 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
|
contract by the capabilities it needs. Run the hermetic set, or grant a
|
||||||
specific external capability when automation needs it:
|
specific external capability when automation needs it:
|
||||||
|
|
||||||
|
|||||||
@@ -134,7 +134,6 @@ ${BOLD}Examples:${RESET}
|
|||||||
$PROGRAM upgrade
|
$PROGRAM upgrade
|
||||||
$PROGRAM apps
|
$PROGRAM apps
|
||||||
$PROGRAM app
|
$PROGRAM app
|
||||||
$PROGRAM app chatgpt-desktop
|
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1805,7 +1805,7 @@ Singleton {
|
|||||||
"org.mozilla.thunderbird_esr", "com.slack.Slack",
|
"org.mozilla.thunderbird_esr", "com.slack.Slack",
|
||||||
"app.bluebubbles.BlueBubbles", "rustdesk",
|
"app.bluebubbles.BlueBubbles", "rustdesk",
|
||||||
"io.podman_desktop.PodmanDesktop", "com.anthropic.Claude",
|
"io.podman_desktop.PodmanDesktop", "com.anthropic.Claude",
|
||||||
"codex-desktop", "md.obsidian.Obsidian",
|
"chatgpt", "md.obsidian.Obsidian",
|
||||||
"com.obsproject.Studio", "steam"
|
"com.obsproject.Studio", "steam"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
Executable
+64
@@ -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."
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
(<https://learn.chatgpt.com/docs/linux/linux-app>) 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 }'
|
||||||
|
```
|
||||||
@@ -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-----
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -79,6 +79,11 @@ source "$PANAMA_PATH/setup/lib/extras-catalog"
|
|||||||
source "$PANAMA_PATH/setup/lib/machine-role"
|
source "$PANAMA_PATH/setup/lib/machine-role"
|
||||||
ROLE="$(panama_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
|
# 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
|
# single rotted name cannot cost the transaction, then report_missing so a
|
||||||
# skipped name is a warning somebody reads.
|
# skipped name is a warning somebody reads.
|
||||||
@@ -369,6 +374,28 @@ else
|
|||||||
|| { log "Claude Desktop install failed; skipping"; softly_failed+=("Claude Desktop"); }
|
|| { log "Claude Desktop install failed; skipping"; softly_failed+=("Claude Desktop"); }
|
||||||
fi
|
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
|
# RustDesk: remote desktop. The flatpak cannot register the root-owned system
|
||||||
# service that unattended access needs -- see panama-doctor's rustdesk check --
|
# 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
|
# so this takes the RPM. The download URL is resolved from the latest release
|
||||||
|
|||||||
@@ -247,6 +247,7 @@ hermetic tests/server/containers-shape-contract
|
|||||||
hermetic tests/server/panama-server-contract
|
hermetic tests/server/panama-server-contract
|
||||||
hermetic tests/setup/apps-contract
|
hermetic tests/setup/apps-contract
|
||||||
hermetic tests/setup/boot-contract
|
hermetic tests/setup/boot-contract
|
||||||
|
hermetic tests/setup/chatgpt-package-contract
|
||||||
hermetic tests/setup/contract-manifest-contract
|
hermetic tests/setup/contract-manifest-contract
|
||||||
hermetic tests/setup/crash-watch-contract
|
hermetic tests/setup/crash-watch-contract
|
||||||
hermetic tests/setup/desktop-first-contract
|
hermetic tests/setup/desktop-first-contract
|
||||||
|
|||||||
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