Skip to content

Commit 27e6064

Browse files
committed
security(apt): sign the published repository
The APT repo shipped no InRelease and no Release.gpg, so the install instructions needed [trusted=yes] -- which tells apt to skip signature verification entirely. That is worse than average here: the package installs a root-invoked fan helper plus a polkit rule, and dpkg maintainer scripts run as root. HTTPS authenticates the host during the transfer; it says nothing about whether the bytes on gh-pages, or in any cache in front of it, are the ones CI built. Signs Release into InRelease and Release.gpg when APT_GPG_PRIVATE_KEY is configured, publishes the public half, and switches the generated index.html to signed-by= instructions. With no key set it publishes unsigned as before but warns -- and deletes any previously published signature, because a stale InRelease beside a regenerated Release breaks apt update outright, which is worse for users than unsigned. Also declares apt-utils: the step calls apt-ftparchive but installed only dpkg-dev, working by luck of the runner image preinstalling it. Verified by extracting the step from the YAML and running it against a throwaway key: signatures verify against the published public key alone, a tampered Release is rejected, and removing the key deletes the stale signature files and reverts the index. docs/development/apt-signing.md covers key generation and the two secrets.
1 parent 386d9f5 commit 27e6064

2 files changed

Lines changed: 177 additions & 4 deletions

File tree

.github/workflows/release.yml

Lines changed: 86 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -135,14 +135,21 @@ jobs:
135135

136136
- name: Update APT repository
137137
if: startsWith(github.ref, 'refs/tags/')
138+
env:
139+
# Unset until a signing key exists, in which case the step publishes
140+
# unsigned and says so loudly rather than failing the release.
141+
APT_GPG_PRIVATE_KEY: ${{ secrets.APT_GPG_PRIVATE_KEY }}
142+
APT_GPG_PASSPHRASE: ${{ secrets.APT_GPG_PASSPHRASE }}
138143
run: |
139144
# This step publishes to a live APT repo that users have in their
140145
# sources.list. A half-finished run takes that repo down, so it fails
141146
# fast rather than pressing on.
142147
set -euo pipefail
143148
144-
# Install dpkg-scanpackages
145-
sudo apt-get install -y dpkg-dev
149+
# dpkg-dev provides dpkg-scanpackages; apt-utils provides
150+
# apt-ftparchive. The runner image happens to preinstall apt-utils, so
151+
# omitting it worked by luck rather than by declaration.
152+
sudo apt-get install -y dpkg-dev apt-utils
146153
147154
# Verify the .deb exists BEFORE touching the published repo. The old
148155
# order deleted the existing package first and copied second, so a glob
@@ -193,22 +200,97 @@ jobs:
193200
# Generate Release file
194201
apt-ftparchive release . > Release
195202
196-
# Create index page
197-
cat > ../index.html << 'HTMLEOF'
203+
# Sign it.
204+
#
205+
# An unsigned repository is why the install instructions needed
206+
# [trusted=yes], a flag that tells apt to skip verification entirely.
207+
# That matters more here than for most repos: this package ships a
208+
# root-invoked fan helper and a polkit rule, and dpkg maintainer
209+
# scripts run as root. HTTPS authenticates the host for the duration
210+
# of the transfer; it says nothing about whether the bytes sitting on
211+
# the branch, or in any cache in front of it, are the ones we built.
212+
if [ -n "${APT_GPG_PRIVATE_KEY:-}" ]; then
213+
GNUPGHOME="$(mktemp -d)"
214+
export GNUPGHOME
215+
chmod 700 "$GNUPGHOME"
216+
217+
printf '%s' "$APT_GPG_PRIVATE_KEY" | gpg --batch --quiet --import
218+
key_id=$(gpg --batch --list-secret-keys --with-colons \
219+
| awk -F: '/^sec:/ {print $5; exit}')
220+
if [ -z "$key_id" ]; then
221+
echo "::error::APT_GPG_PRIVATE_KEY is set but contains no secret key"
222+
exit 1
223+
fi
224+
225+
# InRelease (inline signature) is what modern apt prefers;
226+
# Release.gpg is the detached form older clients still look for.
227+
gpg --batch --yes --pinentry-mode loopback \
228+
--passphrase "${APT_GPG_PASSPHRASE:-}" --local-user "$key_id" \
229+
--clearsign -o InRelease Release
230+
gpg --batch --yes --pinentry-mode loopback \
231+
--passphrase "${APT_GPG_PASSPHRASE:-}" --local-user "$key_id" \
232+
-abs -o Release.gpg Release
233+
234+
# Publish the public half so users can pin it with signed-by=.
235+
gpg --batch --armor --export "$key_id" > thinkutils-archive-keyring.asc
236+
237+
# Fail loudly rather than publishing a signature that does not
238+
# verify -- apt treats a bad signature as harder breakage than none.
239+
gpg --batch --verify Release.gpg Release
240+
echo "signed the Release file with ${key_id}"
241+
242+
rm -rf "$GNUPGHOME"
243+
unset GNUPGHOME
244+
SIGNED=1
245+
else
246+
# Critical: a previous run may have published a signature. Leaving a
247+
# stale InRelease next to a regenerated Release breaks apt outright
248+
# for everyone, which is strictly worse than being unsigned.
249+
rm -f InRelease Release.gpg thinkutils-archive-keyring.asc
250+
SIGNED=0
251+
echo "::warning::APT_GPG_PRIVATE_KEY is not configured. Publishing an \
252+
UNSIGNED repository; users must keep [trusted=yes], which disables \
253+
verification. See docs/development/apt-signing.md to set this up."
254+
fi
255+
256+
# Create index page, matching whichever form was actually published.
257+
if [ "$SIGNED" = "1" ]; then
258+
sources_line='deb [signed-by=/usr/share/keyrings/thinkutils-archive-keyring.gpg] https://gh.vietanh.dev/ThinkUtils/apt ./'
259+
cat > ../index.html << HTMLEOF
198260
<!DOCTYPE html>
199261
<html>
200262
<head><title>ThinkUtils APT Repository</title></head>
201263
<body>
202264
<h1>ThinkUtils APT Repository</h1>
203265
<p>Add this repository to install ThinkUtils via apt:</p>
204266
<pre>
267+
curl -fsSL https://gh.vietanh.dev/ThinkUtils/apt/thinkutils-archive-keyring.asc \\
268+
| sudo gpg --dearmor -o /usr/share/keyrings/thinkutils-archive-keyring.gpg
269+
echo "${sources_line}" | sudo tee /etc/apt/sources.list.d/thinkutils.list
270+
sudo apt update
271+
sudo apt install thinkutils
272+
</pre>
273+
</body>
274+
</html>
275+
HTMLEOF
276+
else
277+
cat > ../index.html << 'HTMLEOF'
278+
<!DOCTYPE html>
279+
<html>
280+
<head><title>ThinkUtils APT Repository</title></head>
281+
<body>
282+
<h1>ThinkUtils APT Repository</h1>
283+
<p><strong>This repository is not signed.</strong> The command below uses
284+
<code>[trusted=yes]</code>, which disables apt's signature checking.</p>
285+
<pre>
205286
echo "deb [trusted=yes] https://gh.vietanh.dev/ThinkUtils/apt ./" | sudo tee /etc/apt/sources.list.d/thinkutils.list
206287
sudo apt update
207288
sudo apt install thinkutils
208289
</pre>
209290
</body>
210291
</html>
211292
HTMLEOF
293+
fi
212294
213295
# Commit and push
214296
cd ..

docs/development/apt-signing.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# Signing the APT repository
2+
3+
The APT repository at `https://gh.vietanh.dev/ThinkUtils/apt` is published by the
4+
`Update APT repository` step in `.github/workflows/release.yml`. That step signs
5+
the `Release` file **if** a signing key is configured, and publishes unsigned
6+
with a loud warning if one is not.
7+
8+
Until the key below exists, the repository is unsigned and users have to install
9+
with `[trusted=yes]`.
10+
11+
## Why this matters here
12+
13+
`[trusted=yes]` tells apt to skip signature verification entirely. For most
14+
repositories that is merely bad practice. For this one it is worse than average:
15+
16+
- the package installs a fan-control helper that runs as root, plus a polkit
17+
rule that grants access to it
18+
- dpkg maintainer scripts run as root at install time
19+
20+
HTTPS authenticates the *host* for the duration of the transfer. It says nothing
21+
about whether the bytes sitting on the `gh-pages` branch, or in any cache in
22+
front of it, are the ones CI built. A signature is what carries that guarantee
23+
from the builder to the user's machine.
24+
25+
## Generating the key
26+
27+
Do this once, on a trusted machine — not in CI.
28+
29+
```bash
30+
# A dedicated key, used for nothing else.
31+
gpg --quick-gen-key "ThinkUtils Archive Signing Key <you@example.com>" \
32+
default default never
33+
34+
# Note the key ID from the output, then export both halves.
35+
KEYID=<key-id-from-above>
36+
gpg --armor --export-secret-keys "$KEYID" > thinkutils-apt-private.asc
37+
gpg --armor --export "$KEYID" > thinkutils-apt-public.asc
38+
```
39+
40+
Keep `thinkutils-apt-private.asc` offline. It is the credential that lets anyone
41+
publish a package your users' machines will install as root.
42+
43+
## Configuring CI
44+
45+
Add two repository secrets (Settings → Secrets and variables → Actions):
46+
47+
| Secret | Value |
48+
| ---------------------- | ------------------------------------------------- |
49+
| `APT_GPG_PRIVATE_KEY` | full contents of `thinkutils-apt-private.asc` |
50+
| `APT_GPG_PASSPHRASE` | the key's passphrase (omit if the key has none) |
51+
52+
The next tagged release will then publish `InRelease`, `Release.gpg`, and
53+
`thinkutils-archive-keyring.asc` alongside the packages, and the generated
54+
`index.html` will switch to `signed-by=` instructions automatically.
55+
56+
## What users run once it is signed
57+
58+
```bash
59+
curl -fsSL https://gh.vietanh.dev/ThinkUtils/apt/thinkutils-archive-keyring.asc \
60+
| sudo gpg --dearmor -o /usr/share/keyrings/thinkutils-archive-keyring.gpg
61+
62+
echo "deb [signed-by=/usr/share/keyrings/thinkutils-archive-keyring.gpg] https://gh.vietanh.dev/ThinkUtils/apt ./" \
63+
| sudo tee /etc/apt/sources.list.d/thinkutils.list
64+
65+
sudo apt update
66+
sudo apt install thinkutils
67+
```
68+
69+
`signed-by=` scopes the key to this one repository, so it cannot be used to
70+
vouch for packages from anywhere else in the user's sources.
71+
72+
## Rotating or removing the key
73+
74+
Changing the key changes what users must trust, so treat it as a release event:
75+
publish the new public key, and expect `apt update` to fail for anyone still
76+
pinning the old one until they re-import.
77+
78+
Removing the secrets makes the next release publish unsigned again. The workflow
79+
deletes any previously published `InRelease`, `Release.gpg`, and keyring file
80+
when it does — a stale signature next to a regenerated `Release` breaks `apt
81+
update` outright, which is worse for users than being unsigned.
82+
83+
## Verifying by hand
84+
85+
```bash
86+
curl -fsSL https://gh.vietanh.dev/ThinkUtils/apt/thinkutils-archive-keyring.asc \
87+
| gpg --import
88+
curl -fsSLO https://gh.vietanh.dev/ThinkUtils/apt/Release
89+
curl -fsSLO https://gh.vietanh.dev/ThinkUtils/apt/Release.gpg
90+
gpg --verify Release.gpg Release
91+
```

0 commit comments

Comments
 (0)